Merge pull request #1637 from shlinkio/develop

Release 3.4.0
This commit is contained in:
Alejandro Celaya 2022-12-16 22:55:02 +01:00 committed by GitHub
commit 067d1cc41c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
214 changed files with 4117 additions and 3911 deletions

View file

@ -41,10 +41,7 @@ runs:
extensions: ${{ inputs.php-extensions }}
coverage: pcov
ini-values: pcov.directory=module
- run: echo "::set-output name=composerArgs::${{ inputs.php-version == '8.2' && '--ignore-platform-req=php' || '' }}"
id: composer_args
shell: bash
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }}
run: composer install --no-interaction --prefer-dist
shell: bash

View file

@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
php-version: ['8.1', '8.2']
continue-on-error: ${{ matrix.php-version == '8.2' }}
env:
LC_ALL: C
steps:
@ -28,7 +27,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1
php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.platform == 'ms' }}

View file

@ -14,13 +14,12 @@ jobs:
strategy:
matrix:
php-version: ['8.1', '8.2']
continue-on-error: ${{ matrix.php-version == '8.2' }}
steps:
- uses: actions/checkout@v3
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
php-extensions: openswoole-4.12.0
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v3
with:

View file

@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
php-version: ['8.1', '8.2']
continue-on-error: ${{ matrix.php-version == '8.2' }}
steps:
- uses: actions/checkout@v3
- name: Start postgres database server
@ -26,7 +25,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
php-extensions: openswoole-4.12.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v3

View file

@ -20,7 +20,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
php-extensions: openswoole-4.12.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }}
@ -44,7 +44,6 @@ jobs:
strategy:
matrix:
php-version: ['8.1', '8.2']
continue-on-error: ${{ matrix.php-version == '8.2' }}
steps:
- uses: actions/checkout@v3
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
@ -52,10 +51,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- run: echo "::set-output name=composerArgs::${{ matrix.php-version == '8.2' && '--ignore-platform-req=php' || '' }}"
id: composer_args
shell: bash
- run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }}
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
- run: composer test:api:rr

View file

@ -8,7 +8,7 @@ on:
- 'v*'
jobs:
build-openswool:
build-openswoole:
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:

View file

@ -10,14 +10,14 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
php-version: ['8.1', '8.2']
swoole: ['yes', 'no']
steps:
- uses: actions/checkout@v3
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
php-extensions: openswoole-4.12.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- if: ${{ matrix.swoole == 'yes' }}
@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
php-version: ['8.1', '8.2']
swoole: ['yes', 'no']
steps:
- uses: geekyeggo/delete-artifact@v1

View file

@ -20,7 +20,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
php-extensions: openswoole-4.12.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}

View file

@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.4.0] - 2022-12-16
### Added
* [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits.
This can be done by:
* Providing `excludeMaxVisitsReached=true` and/or `excludePastValidUntil=true` to the `GET /short-urls` endpoint.
* Providing `--exclude-max-visits-reached` and/or `--exclude-past-valid-until` to the `short-urls:list` command.
* [#1613](https://github.com/shlinkio/shlink/issues/1613) Added amount of visits coming from bots, non-bots and total to every short URL in the short URLs list.
Additionally, added option to order by non-bot visits, by passing `nonBotVisits-DESC` or `nonBotVisits-ASC`.
* [#1599](https://github.com/shlinkio/shlink/issues/1599) Added support for credentials on redis DSNs, either only password, or both username and password.
* [#1616](https://github.com/shlinkio/shlink/issues/1616) Added support to import orphan visits when importing short URLs from another Shlink instance.
* [#1519](https://github.com/shlinkio/shlink/issues/1519) Allowing to search short URLs by default domain.
* [#1555](https://github.com/shlinkio/shlink/issues/1555) and [#1625](https://github.com/shlinkio/shlink/issues/1625) Added full support for PHP 8.2, updating the docker image to this version.
### Changed
* [#1563](https://github.com/shlinkio/shlink/issues/1563) Moved logic to reuse command options to option classes instead of base abstract command classes.
* [#1569](https://github.com/shlinkio/shlink/issues/1569) Migrated test doubles from phpspec/prophecy to PHPUnit mocks.
* [#1329](https://github.com/shlinkio/shlink/issues/1329) Split some logic from `VisitRepository` and `ShortUrlRepository` into separated repository classes.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1618](https://github.com/shlinkio/shlink/issues/1618) Fixed imported short URLs and visits dates not being set to the target server timezone.
* [#1578](https://github.com/shlinkio/shlink/issues/1578) Fixed short URL allowing an empty string as the domain during creation.
* [#1580](https://github.com/shlinkio/shlink/issues/1580) Fixed `FLUSHDB` being run on Shlink docker start-up when using redis, causing full cache to be flushed.
## [3.3.2] - 2022-10-18
### Added
* *Nothing*

View file

@ -1,12 +1,13 @@
FROM php:8.1.9-alpine3.16 as base
FROM php:8.2-alpine3.17 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=openswoole
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV OPENSWOOLE_VERSION 4.11.1
ENV OPENSWOOLE_VERSION 4.12.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV LC_ALL "C"
WORKDIR /etc/shlink
@ -14,7 +15,7 @@ WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
# Temp install dev dependencies needed to compile the extensions
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev && \
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
apk add --no-cache sqlite-libs && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
@ -29,11 +30,11 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
docker-php-ext-enable openswoole ; \
fi; \
if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
docker-php-ext-enable pdo_sqlsrv && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
fi; \
apk del .phpize-deps

View file

@ -7,6 +7,7 @@
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.

View file

@ -21,38 +21,38 @@
"cakephp/chronos": "^2.3",
"doctrine/migrations": "^3.5",
"doctrine/orm": "^2.13.3",
"endroid/qr-code": "^4.4",
"geoip2/geoip2": "^2.12",
"guzzlehttp/guzzle": "^7.4",
"endroid/qr-code": "^4.6",
"geoip2/geoip2": "^2.13",
"guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.110",
"jaybizzle/crawler-detect": "^1.2.112",
"laminas/laminas-config": "^3.7",
"laminas/laminas-config-aggregator": "^1.8",
"laminas/laminas-diactoros": "^2.14",
"laminas/laminas-inputfilter": "^2.19",
"laminas/laminas-servicemanager": "^3.16",
"laminas/laminas-stdlib": "^3.11",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.7",
"laminas/laminas-config-aggregator": "^1.11",
"laminas/laminas-diactoros": "^2.19",
"laminas/laminas-inputfilter": "^2.22",
"laminas/laminas-servicemanager": "^3.19",
"laminas/laminas-stdlib": "^3.15",
"lcobucci/jwt": "^4.2",
"league/uri": "^6.8",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.11",
"mezzio/mezzio-fastroute": "^3.5",
"mezzio/mezzio-problem-details": "^1.6",
"mezzio/mezzio-swoole": "^4.3",
"mezzio/mezzio": "^3.13",
"mezzio/mezzio-fastroute": "^3.7",
"mezzio/mezzio-problem-details": "^1.7",
"mezzio/mezzio-swoole": "^4.5",
"mlocati/ip-lib": "^1.18",
"ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6",
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.0",
"ramsey/uuid": "^4.3",
"shlinkio/shlink-common": "^5.1",
"shlinkio/shlink-config": "^2.1",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.5",
"shlinkio/shlink-common": "^5.2",
"shlinkio/shlink-config": "^2.3",
"shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^4.0",
"shlinkio/shlink-importer": "^5.0",
"shlinkio/shlink-installer": "^8.2",
"shlinkio/shlink-ip-geolocation": "^3.1",
"shlinkio/shlink-ip-geolocation": "^3.2",
"spiral/roadrunner": "^2.11",
"spiral/roadrunner-jobs": "^2.3",
"spiral/roadrunner-jobs": "^2.5",
"symfony/console": "^6.1",
"symfony/filesystem": "^6.1",
"symfony/lock": "^6.1",
@ -64,10 +64,10 @@
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.4.0",
"infection/infection": "^0.26.15",
"openswoole/ide-helper": "~4.11.1",
"phpspec/prophecy-phpunit": "^2.0",
"openswoole/ide-helper": "~4.11.5",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.1",
"phpstan/phpstan-symfony": "^1.2",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
@ -109,7 +109,7 @@
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8",
"test": [
"@parallel test:unit test:db",
"@parallel test:api test:cli"
@ -132,10 +132,10 @@
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli",
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
"infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5",
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=80 --configuration=infection-cli.json5",
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli",
"infect:test": [
"@parallel test:unit:ci test:db:sqlite:ci test:api:ci",

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)];
$cacheRedisBlock = $redisServers === null ? [] : [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
],
];
return [
'cache' => [
'namespace' => 'Shlink',
...$cacheRedisBlock,
],
'redis' => $redis,
];
})();

View file

@ -42,6 +42,9 @@ return (static function (): array {
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
'charset' => $resolveCharset(),
'driverOptions' => $driver !== 'mssql' ? [] : [
'TrustServerCertificate' => 'true',
],
],
};

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
$pubSub = [
'redis' => [
'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false),
],
];
return match ($redisServers) {
null => $pubSub,
default => [
'cache' => [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
],
],
...$pubSub,
],
};
})();

View file

@ -7,6 +7,8 @@ return [
'cache' => [
'redis' => [
'servers' => 'tcp://shlink_redis:6379',
// 'servers' => 'tcp://barbar@shlink_redis_acl:6379',
// 'servers' => 'tcp://foo:bar@shlink_redis_acl:6379',
],
],

View file

@ -101,6 +101,9 @@ $buildDbConnection = static function (): array {
'user' => 'sa',
'password' => 'Passw0rd!',
'dbname' => 'shlink_test',
'driverOptions' => [
'TrustServerCertificate' => 'true',
],
],
default => [ // mysql and maria
'driver' => 'pdo_mysql',

View file

@ -1,9 +1,10 @@
FROM php:8.1.9-fpm-alpine3.16
FROM php:8.2-fpm-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
@ -30,7 +31,9 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN docker-php-ext-install sockets
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
@ -44,13 +47,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View file

@ -0,0 +1,2 @@
user foo allcommands allkeys on >bar
requirepass barbar

View file

@ -1,9 +1,10 @@
FROM php:8.1.9-alpine3.16
FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
@ -30,7 +31,9 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN docker-php-ext-install sockets
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
@ -44,13 +47,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View file

@ -1,11 +1,12 @@
FROM php:8.1.9-alpine3.16
FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 4.11.1
ENV OPENSWOOLE_VERSION 4.12.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
@ -32,7 +33,9 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN docker-php-ext-install sockets
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
@ -54,13 +57,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \
&& rm /tmp/inotify.tar.gz
# Install openswoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable openswoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View file

@ -29,6 +29,7 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
@ -65,6 +66,7 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
@ -89,6 +91,7 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
@ -146,10 +149,19 @@ services:
shlink_redis:
container_name: shlink_redis
image: redis:6.0-alpine
image: redis:6.2-alpine
ports:
- "6380:6379"
shlink_redis_acl:
container_name: shlink_redis_acl
image: redis:6.2-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports:
- "6382:6379"
volumes:
- ./data/infra/redis/redis-acl.conf:/usr/local/etc/redis/redis.conf
shlink_mercure_proxy:
container_name: shlink_mercure_proxy
image: nginx:1.19.6-alpine

View file

@ -6,6 +6,7 @@
"longUrl",
"dateCreated",
"visitsCount",
"visitsSummary",
"tags",
"meta",
"domain",
@ -32,8 +33,12 @@
"description": "The date in which the short URL was created in ISO format."
},
"visitsCount": {
"deprecated": true,
"type": "integer",
"description": "The number of visits that this short URL has received."
"description": "**[DEPRECATED]** Use `visitsSummary.total` instead."
},
"visitsSummary": {
"$ref": "./ShortUrlVisitsSummary.json"
},
"tags": {
"type": "array",

View file

@ -0,0 +1,18 @@
{
"type": "object",
"required": ["total", "nonBots", "bots"],
"properties": {
"total": {
"description": "The total amount of visits that this short URL has received.",
"type": "integer"
},
"nonBots": {
"description": "The amount of visits which were not identified as bots.",
"type": "integer"
},
"bots": {
"description": "The amount of visits that were identified as potential bots.",
"type": "integer"
}
}
}

View file

@ -73,10 +73,12 @@
"shortCode-DESC",
"dateCreated-ASC",
"dateCreated-DESC",
"title-ASC",
"title-DESC",
"visits-ASC",
"visits-DESC",
"title-ASC",
"title-DESC"
"nonBotVisits-ASC",
"nonBotVisits-DESC"
]
}
},
@ -97,6 +99,32 @@
"schema": {
"type": "string"
}
},
{
"name": "excludeMaxVisitsReached",
"in": "query",
"description": "If true, short URLs which already reached their maximum amount of visits will be excluded.",
"required": false,
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
},
{
"name": "excludePastValidUntil",
"in": "query",
"description": "If true, short URLs which validUntil date is on the past will be excluded.",
"required": false,
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
],
"security": [
@ -136,7 +164,11 @@
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"visitsSummary": {
"total": 328,
"nonBots": 328,
"bots": 0
},
"tags": [
"games",
"tech"
@ -155,7 +187,11 @@
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"visitsSummary": {
"total": 1029,
"nonBots": 900,
"bots": 129
},
"tags": [
"shlink"
],
@ -173,7 +209,11 @@
"shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"visitsSummary": {
"total": 25,
"nonBots": 0,
"bots": 25
},
"tags": [],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
@ -281,7 +321,11 @@
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"visitsSummary": {
"total": 0,
"nonBots": 0,
"bots": 0
},
"tags": [
"games",
"tech"

View file

@ -55,7 +55,11 @@
"shortUrl": "https://doma.in/abc123",
"shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"visitsSummary": {
"total": 0,
"nonBots": 0,
"bots": 0
},
"tags": [
"games",
"tech"

View file

@ -41,7 +41,11 @@
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"visitsSummary": {
"total": 1029,
"nonBots": 820,
"bots": 209
},
"tags": [
"shlink"
],
@ -159,7 +163,11 @@
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"visitsSummary": {
"total": 1029,
"nonBots": 900,
"bots": 129
},
"tags": [
"shlink"
],

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Run docker containers if they are not up yet
if ! [[ $(docker ps | grep shlink) ]]; then
if ! [[ $(docker ps | grep shlink_swoole) ]]; then
docker-compose up -d
fi

View file

@ -84,7 +84,7 @@ return [
],
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
ShortUrl\ShortUrlService::class,
ShortUrl\ShortUrlListService::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
],
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Input\InputInterface;
use function is_string;
@ -19,8 +20,8 @@ class RoleResolver implements RoleResolverInterface
public function determineRoles(InputInterface $input): array
{
$domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM);
$author = $input->getOption(self::AUTHOR_ONLY_PARAM);
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
$roleDefinitions = [];
if ($author) {

View file

@ -9,9 +9,6 @@ use Symfony\Component\Console\Input\InputInterface;
interface RoleResolverInterface
{
public const AUTHOR_ONLY_PARAM = 'author-only';
public const DOMAIN_ONLY_PARAM = 'domain-only';
/**
* @return RoleDefinition[]
*/

View file

@ -32,8 +32,8 @@ class GenerateKeyCommand extends Command
protected function configure(): void
{
$authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM;
$domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM;
$authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
$domainOnly = Role::DOMAIN_SPECIFIC->paramName();
$help = <<<HELP
The <info>%command.name%</info> generates a new valid API key.

View file

@ -62,8 +62,8 @@ class ListKeysCommand extends Command
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (Role $role, array $meta) =>
empty($meta)
? Role::toFriendlyName($role)
: sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)),
? $role->toFriendlyName()
: sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)),
));
return $rowData;

View file

@ -25,7 +25,7 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)

View file

@ -20,7 +20,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'short-url:visits';
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)

View file

@ -4,7 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Option\EndDateOption;
use Shlinkio\Shlink\CLI\Option\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
@ -14,7 +15,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@ -27,20 +29,25 @@ use function Functional\map;
use function implode;
use function sprintf;
class ListShortUrlsCommand extends AbstractWithDateRangeCommand
class ListShortUrlsCommand extends Command
{
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(
private ShortUrlServiceInterface $shortUrlService,
private DataTransformerInterface $transformer,
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly DataTransformerInterface $transformer,
) {
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
$this->endDateOption = new EndDateOption($this, 'short URLs');
}
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)
@ -70,6 +77,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
InputOption::VALUE_NONE,
'If tags is provided, returns only short URLs having ALL tags.',
)
->addOption(
'exclude-max-visits-reached',
null,
InputOption::VALUE_NONE,
'Excludes short URLs which reached their max amount of visits.',
)
->addOption(
'exclude-past-valid-until',
null,
InputOption::VALUE_NONE,
'Excludes short URLs which have a "validUntil" date in the past.',
)
->addOption(
'order-by',
'o',
@ -104,16 +123,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
);
}
protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName);
}
protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
@ -124,8 +133,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$orderBy = $this->processOrderBy($input);
$columnsMap = $this->resolveColumnsMap($input);
@ -136,6 +145,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
];
if ($all) {

View file

@ -25,7 +25,7 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function is_string;
use function sprintf;
abstract class AbstractWithDateRangeCommand extends Command
{
private const START_DATE = 'start-date';
private const END_DATE = 'end-date';
final protected function configure(): void
{
$this->doConfigure();
$this
->addOption(self::START_DATE, 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc(self::START_DATE))
->addOption(self::END_DATE, 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc(self::END_DATE));
}
protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
{
return $this->getDateOption($input, $output, self::START_DATE);
}
protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos
{
return $this->getDateOption($input, $output, self::END_DATE);
}
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
{
$value = $input->getOption($key);
if (empty($value) || ! is_string($value)) {
return null;
}
try {
return Chronos::parse($value);
} catch (Throwable $e) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$key,
$value,
));
if ($output->isVeryVerbose()) {
$this->getApplication()?->renderThrowable($e, $output);
}
return null;
}
}
abstract protected function doConfigure(): void;
abstract protected function getStartDateDesc(string $optionName): string;
abstract protected function getEndDateDesc(string $optionName): string;
}

View file

@ -4,13 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Option\EndDateOption;
use Shlinkio\Shlink\CLI\Option\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -19,29 +21,23 @@ use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
use function sprintf;
abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand
abstract class AbstractVisitsListCommand extends Command
{
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
final protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
final protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
$this->startDateOption = new StartDateOption($this, 'visits');
$this->endDateOption = new EndDateOption($this, 'visits');
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);

View file

@ -23,7 +23,7 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)

View file

@ -14,7 +14,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:orphan';
protected function doConfigure(): void
protected function configure(): void
{
$this
->setName(self::NAME)

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function is_string;
use function sprintf;
class DateOption
{
public function __construct(
private readonly Command $command,
private readonly string $name,
string $shortcut,
string $description,
) {
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
{
$value = $input->getOption($this->name);
if (empty($value) || ! is_string($value)) {
return null;
}
try {
return Chronos::parse($value);
} catch (Throwable $e) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$this->name,
$value,
));
if ($output->isVeryVerbose()) {
$this->command->getApplication()?->renderThrowable($e, $output);
}
return null;
}
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
class EndDateOption
{
private readonly DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->dateOption = new DateOption($command, 'end-date', 'e', sprintf(
'Allows to filter %s, returning only those newer than provided date.',
$descriptionHint,
));
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
{
return $this->dateOption->get($input, $output);
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
class StartDateOption
{
private readonly DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->dateOption = new DateOption($command, 'start-date', 's', sprintf(
'Allows to filter %s, returning only those older than provided date.',
$descriptionHint,
));
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
{
return $this->dateOption->get($input, $output);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class ListShortUrlsTest extends CliTestCase
{
/**
* @test
* @dataProvider provideFlagsAndOutput
*/
public function generatesExpectedOutput(array $flags, string $expectedOutput): void
{
[$output] = $this->exec([ListShortUrlsCommand::NAME, ...$flags], ['no']);
self::assertStringContainsString($expectedOutput, $output);
}
public function provideFlagsAndOutput(): iterable
{
// phpcs:disable Generic.Files.LineLength
yield 'no flags' => [[], <<<OUTPUT
+--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
| abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
| ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+--------------------+---------------+-------------------------------------------+---------------------------- Page 1 of 1 ------------------------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'start date' => [['--start-date=2019-01'], <<<OUTPUT
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'end date' => [['-e 2018-12-01'], <<<OUTPUT
+--------------------+---------------+-------------------------------------------+----------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+--------------------+---------------+-------------------------------------------+----------------------------------+---------------------------+--------------+
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
| abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
| ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+--------------------+---------------+----------------------------------- Page 1 of 1 ------------------------------+---------------------------+--------------+
OUTPUT];
yield 'start and end date' => [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<<OUTPUT
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
+--------------------+-------+-------------------------------------------+----------------------------- Page 1 of 1 -----------------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'expired excluded' => [['--exclude-max-visits-reached', '--exclude-past-valid-until'], <<<OUTPUT
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
OUTPUT];
// phpcs:enable
}
}

View file

@ -4,27 +4,27 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\ApiKey;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Input\InputInterface;
use function Functional\map;
class RoleResolverTest extends TestCase
{
use ProphecyTrait;
private RoleResolver $resolver;
private ObjectProphecy $domainService;
private MockObject & DomainServiceInterface $domainService;
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com');
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService, 'default.com');
}
/**
@ -36,61 +36,63 @@ class RoleResolverTest extends TestCase
array $expectedRoles,
int $expectedDomainCalls,
): void {
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
Domain::withAuthority('example.com')->setId('1'),
);
$this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with(
'example.com',
)->willReturn($this->domainWithId(Domain::withAuthority('example.com')));
$result = $this->resolver->determineRoles($input);
self::assertEquals($expectedRoles, $result);
$getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
}
public function provideRoles(): iterable
{
$domain = Domain::withAuthority('example.com')->setId('1');
$domain = $this->domainWithId(Domain::withAuthority('example.com'));
$buildInput = function (array $definition): InputInterface {
$input = $this->prophesize(InputInterface::class);
$input = $this->createStub(InputInterface::class);
$input->method('getOption')->willReturnMap(
map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]),
);
foreach ($definition as $name => $value) {
$input->getOption($name)->willReturn($value);
}
return $input->reveal();
return $input;
};
yield 'no roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]),
[],
0,
];
yield 'domain role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
$buildInput(
[Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false],
),
[RoleDefinition::forDomain($domain)],
1,
];
yield 'false domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]),
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]),
[],
0,
];
yield 'true domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]),
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]),
[],
0,
];
yield 'string array domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]),
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]),
[],
0,
];
yield 'author role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]),
[RoleDefinition::forAuthoredShortUrls()],
0,
];
yield 'both roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
$buildInput(
[Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true],
),
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
1,
];
@ -99,12 +101,22 @@ class RoleResolverTest extends TestCase
/** @test */
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
{
$input = $this->prophesize(InputInterface::class);
$input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com');
$input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null);
$input = $this->createStub(InputInterface::class);
$input
->method('getOption')
->willReturnMap([
[Role::DOMAIN_SPECIFIC->paramName(), 'default.com'],
[Role::AUTHORED_SHORT_URLS->paramName(), null],
]);
$this->expectException(InvalidRoleConfigException::class);
$this->resolver->determineRoles($input->reveal());
$this->resolver->determineRoles($input);
}
private function domainWithId(Domain $domain): Domain
{
$domain->setId('1');
return $domain;
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
@ -14,21 +13,14 @@ use Symfony\Component\Console\Tester\CommandTester;
trait CliTestUtilsTrait
{
use ProphecyTrait;
/**
* @return ObjectProphecy|Command
*/
private function createCommandMock(string $name): ObjectProphecy
private function createCommandMock(string $name): MockObject & Command
{
$command = $this->prophesize(Command::class);
$command->getName()->willReturn($name);
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
$command->getDefinition()->willReturn(new InputDefinition());
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
});
$command = $this->createMock(Command::class);
$command->method('getName')->willReturn($name);
$command->method('isEnabled')->willReturn(true);
$command->method('getAliases')->willReturn([]);
$command->method('getDefinition')->willReturn(new InputDefinition());
$command->method('setApplication')->with(Assert::isInstanceOf(Application::class));
return $command;
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -17,19 +17,19 @@ class DisableKeyCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal()));
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService));
}
/** @test */
public function providedApiKeyIsDisabled(): void
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey);
$this->commandTester->execute([
'apiKey' => $apiKey,
@ -44,7 +44,9 @@ class DisableKeyCommandTest extends TestCase
{
$apiKey = 'abcd1234';
$expectedMessage = 'API key "abcd1234" does not exist.';
$disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage));
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
$this->commandTester->execute([
'apiKey' => $apiKey,
@ -52,6 +54,5 @@ class DisableKeyCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -21,22 +20,25 @@ class GenerateKeyCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$roleResolver = $this->prophesize(RoleResolverInterface::class);
$roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$roleResolver = $this->createMock(RoleResolverInterface::class);
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal());
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
public function noExpirationDateIsDefinedIfNotProvided(): void
{
$this->apiKeyService->create(null, null)->shouldBeCalledOnce()->willReturn(ApiKey::create());
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(),
$this->isNull(),
)->willReturn(ApiKey::create());
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@ -47,9 +49,10 @@ class GenerateKeyCommandTest extends TestCase
/** @test */
public function expirationDateIsDefinedIfProvided(): void
{
$this->apiKeyService->create(Argument::type(Chronos::class), null)->shouldBeCalledOnce()->willReturn(
ApiKey::create(),
);
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isInstanceOf(Chronos::class),
$this->isNull(),
)->willReturn(ApiKey::create());
$this->commandTester->execute([
'--expiration-date' => '2016-01-01',
@ -59,9 +62,10 @@ class GenerateKeyCommandTest extends TestCase
/** @test */
public function nameIsDefinedIfProvided(): void
{
$this->apiKeyService->create(null, Argument::type('string'))->shouldBeCalledOnce()->willReturn(
ApiKey::create(),
);
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(),
$this->isType('string'),
)->willReturn(ApiKey::create());
$this->commandTester->execute([
'--name' => 'Alice',

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
@ -21,12 +21,12 @@ class ListKeysCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal()));
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService));
}
/**
@ -35,13 +35,12 @@ class ListKeysCommandTest extends TestCase
*/
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
$this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys);
$this->commandTester->execute(['--enabled-only' => $enabledOnly]);
$output = $this->commandTester->getDisplay();
self::assertEquals($expected, $output);
$listKeys->shouldHaveBeenCalledOnce();
}
public function provideKeysAndOutputs(): iterable
@ -87,12 +86,12 @@ class ListKeysCommandTest extends TestCase
$apiKey1 = ApiKey::create(),
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
$apiKey3 = $this->apiKeyWithRoles(
[RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))],
[RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com')))],
),
$apiKey4 = ApiKey::create(),
$apiKey5 = $this->apiKeyWithRoles([
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com'))),
]),
$apiKey6 = ApiKey::create(),
],
@ -151,4 +150,10 @@ class ListKeysCommandTest extends TestCase
return $apiKey;
}
private function domainWithId(Domain $domain): Domain
{
$domain->setId('1');
return $domain;
}
}

View file

@ -9,9 +9,8 @@ use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@ -28,40 +27,37 @@ class CreateDatabaseCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $processHelper;
private ObjectProphecy $regularConn;
private ObjectProphecy $schemaManager;
private ObjectProphecy $driver;
private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn;
private MockObject & AbstractSchemaManager $schemaManager;
private MockObject & Driver $driver;
protected function setUp(): void
{
$locker = $this->prophesize(LockFactory::class);
$lock = $this->prophesize(LockInterface::class);
$lock->acquire(Argument::any())->willReturn(true);
$lock->release()->will(function (): void {
});
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(LockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$this->driver = $this->prophesize(Driver::class);
$this->regularConn->getDriver()->willReturn($this->driver->reveal());
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal());
$noDbNameConn = $this->prophesize(Connection::class);
$noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn = $this->createMock(Connection::class);
$this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager);
$this->driver = $this->createMock(Driver::class);
$this->regularConn->method('getDriver')->willReturn($this->driver);
$noDbNameConn = $this->createMock(Connection::class);
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand(
$locker->reveal(),
$this->processHelper->reveal(),
$phpExecutableFinder->reveal(),
$this->regularConn->reveal(),
$noDbNameConn->reveal(),
$locker,
$this->processHelper,
$phpExecutableFinder,
$this->regularConn,
$noDbNameConn,
);
$this->commandTester = $this->testerForCommand($command);
@ -71,38 +67,33 @@ class CreateDatabaseCommandTest extends TestCase
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
['foo', $shlinkDatabase, 'bar'],
);
$this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
}
/** @test */
public function databaseIsCreatedIfItDoesNotExist(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]);
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']);
$this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase);
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(
['foo_table', 'bar_table', MIGRATIONS_TABLE],
);
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
$this->commandTester->execute([]);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldHaveBeenCalledOnce();
$listTables->shouldHaveBeenCalledOnce();
}
/**
@ -112,28 +103,25 @@ class CreateDatabaseCommandTest extends TestCase
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn($tables);
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
['foo', $shlinkDatabase, 'bar'],
);
$this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables);
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_SCRIPT,
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
'--no-interaction',
]);
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Creating database tables...', $output);
self::assertStringContainsString('Database properly created!', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
$runCommand->shouldHaveBeenCalledOnce();
}
public function provideEmptyDatabase(): iterable
@ -145,20 +133,13 @@ class CreateDatabaseCommandTest extends TestCase
/** @test */
public function databaseCheckIsSkippedForSqlite(): void
{
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal());
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class));
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$this->regularConn->expects($this->never())->method('getParams');
$this->schemaManager->expects($this->never())->method('listDatabases');
$this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);
$this->commandTester->execute([]);
$getDatabase->shouldNotHaveBeenCalled();
$listDatabases->shouldNotHaveBeenCalled();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@ -21,34 +20,28 @@ class MigrateDatabaseCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $processHelper;
private MockObject & ProcessRunnerInterface $processHelper;
protected function setUp(): void
{
$locker = $this->prophesize(LockFactory::class);
$lock = $this->prophesize(LockInterface::class);
$lock->acquire(Argument::any())->willReturn(true);
$lock->release()->will(function (): void {
});
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(LockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$command = new MigrateDatabaseCommand(
$locker->reveal(),
$this->processHelper->reveal(),
$phpExecutableFinder->reveal(),
);
$command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder);
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
public function migrationsCommandIsRunWithProperVerbosity(): void
{
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
'/usr/local/bin/php',
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
@ -60,6 +53,5 @@ class MigrateDatabaseCommandTest extends TestCase
self::assertStringContainsString('Migrating database...', $output);
self::assertStringContainsString('Database properly migrated!', $output);
$runCommand->shouldHaveBeenCalledOnce();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
@ -22,12 +22,12 @@ class DomainRedirectsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $domainService;
private MockObject & DomainServiceInterface $domainService;
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService));
}
/**
@ -37,11 +37,14 @@ class DomainRedirectsCommandTest extends TestCase
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
{
$domainAuthority = 'my-domain.com';
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
$domain,
);
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
$domainAuthority,
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
)->willReturn(Domain::withAuthority(''));
$this->domainService->expects($this->never())->method('listDomains');
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
$this->commandTester->execute(['domain' => $domainAuthority]);
@ -55,9 +58,6 @@ class DomainRedirectsCommandTest extends TestCase
);
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
}
public function provideDomains(): iterable
@ -73,11 +73,14 @@ class DomainRedirectsCommandTest extends TestCase
$domain = Domain::withAuthority($domainAuthority);
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
$domain,
);
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
$domainAuthority,
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
)->willReturn($domain);
$this->domainService->expects($this->never())->method('listDomains');
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
$this->commandTester->execute(['domain' => $domainAuthority]);
@ -90,9 +93,6 @@ class DomainRedirectsCommandTest extends TestCase
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
self::assertEquals(3, substr_count($output, 'Remove redirect'));
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
}
/** @test */
@ -101,9 +101,11 @@ class DomainRedirectsCommandTest extends TestCase
$domainAuthority = 'example.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([]);
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
$domain,
);
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
@ -113,9 +115,6 @@ class DomainRedirectsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
/** @test */
@ -124,13 +123,15 @@ class DomainRedirectsCommandTest extends TestCase
$domainAuthority = 'existing-two.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority($domainAuthority)),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
$domain,
);
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
@ -143,9 +144,6 @@ class DomainRedirectsCommandTest extends TestCase
self::assertStringNotContainsString('default-domain.com', $output);
self::assertStringContainsString('existing-one.com', $output);
self::assertStringContainsString($domainAuthority, $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
/** @test */
@ -154,13 +152,15 @@ class DomainRedirectsCommandTest extends TestCase
$domainAuthority = 'new-domain.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-two.com')),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
$domain,
);
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
@ -173,8 +173,5 @@ class DomainRedirectsCommandTest extends TestCase
self::assertStringNotContainsString('default-domain.com', $output);
self::assertStringContainsString('existing-one.com', $output);
self::assertStringContainsString('existing-two.com', $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -25,16 +24,16 @@ class GetDomainVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
);
}
@ -46,10 +45,13 @@ class GetDomainVisitsCommandTest extends TestCase
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$domain = 'doma.in';
$getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
$this->visitsHelper->expects($this->once())->method('visitsForDomain')->with(
$domain,
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'the_short_url',
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
@ -65,7 +67,5 @@ class GetDomainVisitsCommandTest extends TestCase
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
@ -21,12 +21,12 @@ class ListDomainsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $domainService;
private MockObject & DomainServiceInterface $domainService;
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService));
}
/**
@ -42,7 +42,7 @@ class ListDomainsCommandTest extends TestCase
'https://foo.com/baz-domain/invalid',
));
$listDomains = $this->domainService->listDomains()->willReturn([
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions(
invalidShortUrl: 'https://foo.com/default/invalid',
baseUrl: 'https://foo.com/default/base',
@ -55,7 +55,6 @@ class ListDomainsCommandTest extends TestCase
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$listDomains->shouldHaveBeenCalledOnce();
}
public function provideInputsAndOutputs(): iterable

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@ -16,7 +15,7 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
@ -27,19 +26,21 @@ class CreateShortUrlCommandTest extends TestCase
private const DEFAULT_DOMAIN = 'default.com';
private CommandTester $commandTester;
private ObjectProphecy $urlShortener;
private ObjectProphecy $stringifier;
private MockObject & UrlShortenerInterface $urlShortener;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
$this->urlShortener = $this->createMock(UrlShortenerInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$command = new CreateShortUrlCommand(
$this->urlShortener->reveal(),
$this->stringifier->reveal(),
new UrlShortenerOptions(domain: ['hostname' => self::DEFAULT_DOMAIN], defaultShortCodesLength: 5),
$this->urlShortener,
$this->stringifier,
new UrlShortenerOptions(
domain: ['hostname' => self::DEFAULT_DOMAIN, 'schema' => ''],
defaultShortCodesLength: 5,
),
);
$this->commandTester = $this->testerForCommand($command);
}
@ -48,8 +49,10 @@ class CreateShortUrlCommandTest extends TestCase
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
@ -59,16 +62,16 @@ class CreateShortUrlCommandTest extends TestCase
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
/** @test */
public function exceptionWhileParsingLongUrlOutputsError(): void
{
$url = 'http://domain.com/invalid';
$this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
->shouldBeCalledOnce();
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
InvalidUrlException::fromUrl($url),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay();
@ -80,30 +83,32 @@ class CreateShortUrlCommandTest extends TestCase
/** @test */
public function providingNonUniqueSlugOutputsError(): void
{
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow(
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
/** @test */
public function properlyProcessesProvidedTags(): void
{
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(
Argument::that(function (ShortUrlCreation $meta) {
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) {
$tags = $meta->getTags();
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
return true;
}),
)->willReturn($shortUrl);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
@ -113,8 +118,6 @@ class CreateShortUrlCommandTest extends TestCase
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
/**
@ -123,18 +126,18 @@ class CreateShortUrlCommandTest extends TestCase
*/
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
{
$shorten = $this->urlShortener->shorten(
Argument::that(function (ShortUrlCreation $meta) use ($expectedDomain) {
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
Assert::assertEquals($expectedDomain, $meta->getDomain());
return true;
}),
)->willReturn(ShortUrl::createEmpty());
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$shorten->shouldHaveBeenCalledOnce();
}
public function provideDomains(): iterable
@ -152,17 +155,16 @@ class CreateShortUrlCommandTest extends TestCase
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
{
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(
Argument::that(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
return $meta;
return true;
}),
)->willReturn($shortUrl);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
public function provideFlags(): iterable

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
@ -14,7 +13,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
use function array_pop;
use function sprintf;
use const PHP_EOL;
@ -24,23 +22,22 @@ class DeleteShortUrlCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $service;
private MockObject & DeleteShortUrlServiceInterface $service;
protected function setUp(): void
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal()));
$this->service = $this->createMock(DeleteShortUrlServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service));
}
/** @test */
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(
$this->service->expects($this->once())->method('deleteByShortCode')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->will(function (): void {
});
$this->isFalse(),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@ -49,7 +46,6 @@ class DeleteShortUrlCommandTest extends TestCase
sprintf('Short URL with short code "%s" successfully deleted.', $shortCode),
$output,
);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/** @test */
@ -57,15 +53,15 @@ class DeleteShortUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
);
$this->service->expects($this->once())->method('deleteByShortCode')->with(
$identifier,
$this->isFalse(),
)->willThrowException(Exception\ShortUrlNotFoundException::fromNotFound($identifier));
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@ -79,18 +75,17 @@ class DeleteShortUrlCommandTest extends TestCase
): void {
$shortCode = 'abc123';
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
function (array $args) use ($shortCode): void {
$ignoreThreshold = array_pop($args);
if (!$ignoreThreshold) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
);
}
},
);
$this->service->expects($this->exactly($expectedDeleteCalls))->method('deleteByShortCode')->with(
$identifier,
$this->isType('bool'),
)->willReturnCallback(function ($_, bool $ignoreThreshold) use ($shortCode): void {
if (!$ignoreThreshold) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
);
}
});
$this->commandTester->setInputs($retryAnswer);
$this->commandTester->execute(['shortCode' => $shortCode]);
@ -101,7 +96,6 @@ class DeleteShortUrlCommandTest extends TestCase
$shortCode,
), $output);
self::assertStringContainsString($expectedMessage, $output);
$deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls);
}
public function provideRetryDeleteAnswers(): iterable
@ -115,10 +109,10 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(
$this->service->expects($this->once())->method('deleteByShortCode')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->isFalse(),
)->willThrowException(Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
));
@ -132,6 +126,5 @@ class DeleteShortUrlCommandTest extends TestCase
$shortCode,
), $output);
self::assertStringContainsString('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View file

@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
@ -31,12 +30,12 @@ class GetShortUrlVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal());
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$command = new GetShortUrlVisitsCommand($this->visitsHelper);
$this->commandTester = $this->testerForCommand($command);
}
@ -44,12 +43,10 @@ class GetShortUrlVisitsCommandTest extends TestCase
public function noDateFlagsTriesToListWithoutDateRange(): void
{
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['shortCode' => $shortCode]);
}
@ -60,12 +57,10 @@ class GetShortUrlVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsHelper->visitsForShortUrl(
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
@ -79,7 +74,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$startDate = 'foo';
$info = $this->visitsHelper->visitsForShortUrl(
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
@ -90,7 +85,6 @@ class GetShortUrlVisitsCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$info->shouldHaveBeenCalledOnce();
self::assertStringContainsString(
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
$output,
@ -104,12 +98,10 @@ class GetShortUrlVisitsCommandTest extends TestCase
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
Argument::any(),
)->willReturn(
new Paginator(new ArrayAdapter([$visit])),
)->shouldBeCalledOnce();
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();

View file

@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -16,7 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -31,12 +30,12 @@ class ListShortUrlsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $shortUrlService;
private MockObject & ShortUrlListServiceInterface $shortUrlService;
protected function setUp(): void
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(
$this->shortUrlService = $this->createMock(ShortUrlListServiceInterface::class);
$command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer(
new ShortUrlStringifier([]),
));
$this->commandTester = $this->testerForCommand($command);
@ -51,9 +50,8 @@ class ListShortUrlsCommandTest extends TestCase
$data[] = ShortUrl::withLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())
->will(fn () => new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(3);
$this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters()
->willReturnCallback(fn () => new Paginator(new ArrayAdapter($data)));
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute([]);
@ -74,9 +72,9 @@ class ListShortUrlsCommandTest extends TestCase
$data[] = ShortUrl::withLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledOnce();
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
)->willReturn(new Paginator(new ArrayAdapter($data)));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute([]);
@ -95,9 +93,9 @@ class ListShortUrlsCommandTest extends TestCase
public function passingPageWillMakeListStartOnThatPage(): void
{
$page = 5;
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::fromRawData(['page' => $page]),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['y']);
$this->commandTester->execute(['--page' => $page]);
@ -113,15 +111,15 @@ class ListShortUrlsCommandTest extends TestCase
array $notExpectedContents,
ApiKey $apiKey,
): void {
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter([
ShortUrl::fromMeta(ShortUrlCreation::fromRawData([
'longUrl' => 'foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => $apiKey,
])),
])))
->shouldBeCalledOnce();
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
)->willReturn(new Paginator(new ArrayAdapter([
ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => $apiKey,
])),
])));
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($input);
@ -189,7 +187,7 @@ class ListShortUrlsCommandTest extends TestCase
?string $startDate = null,
?string $endDate = null,
): void {
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'page' => $page,
'searchTerm' => $searchTerm,
'tags' => $tags,
@ -200,8 +198,6 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
$listShortUrls->shouldHaveBeenCalledOnce();
}
public function provideArgs(): iterable
@ -251,14 +247,12 @@ class ListShortUrlsCommandTest extends TestCase
*/
public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
{
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
$listShortUrls->shouldHaveBeenCalledOnce();
}
public function provideOrderBy(): iterable
@ -273,7 +267,7 @@ class ListShortUrlsCommandTest extends TestCase
/** @test */
public function requestingAllElementsWillSetItemsPerPage(): void
{
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'page' => 1,
'searchTerm' => null,
'tags' => [],
@ -285,7 +279,5 @@ class ListShortUrlsCommandTest extends TestCase
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['--all' => true]);
$listShortUrls->shouldHaveBeenCalledOnce();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -23,12 +23,12 @@ class ResolveUrlCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $urlResolver;
private MockObject & ShortUrlResolverInterface $urlResolver;
protected function setUp(): void
{
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal()));
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver));
}
/** @test */
@ -37,9 +37,9 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = ShortUrl::withLongUrl($expectedUrl);
$this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn(
$shortUrl,
)->shouldBeCalledOnce();
$this->urlResolver->expects($this->once())->method('resolveShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
)->willReturn($shortUrl);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@ -52,9 +52,9 @@ class ResolveUrlCommandTest extends TestCase
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123');
$shortCode = $identifier->shortCode;
$this->urlResolver->resolveShortUrl($identifier)
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))
->shouldBeCalledOnce();
$this->urlResolver->expects($this->once())->method('resolveShortUrl')->with($identifier)->willThrowException(
ShortUrlNotFoundException::fromNotFound($identifier),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@ -16,12 +16,12 @@ class DeleteTagsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal()));
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService));
}
/** @test */
@ -37,8 +37,7 @@ class DeleteTagsCommandTest extends TestCase
public function serviceIsInvokedOnSuccess(): void
{
$tagNames = ['foo', 'bar'];
$deleteTags = $this->tagService->deleteTags($tagNames)->will(function (): void {
});
$this->tagService->expects($this->once())->method('deleteTags')->with($tagNames);
$this->commandTester->execute([
'--name' => $tagNames,
@ -46,6 +45,5 @@ class DeleteTagsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Tags properly deleted', $output);
$deleteTags->shouldHaveBeenCalled();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -25,16 +24,16 @@ class GetTagVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
new GetTagVisitsCommand($this->visitsHelper, $this->stringifier),
);
}
@ -46,10 +45,10 @@ class GetTagVisitsCommandTest extends TestCase
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$tag = 'abc123';
$getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn(
$this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['tag' => $tag]);
$output = $this->commandTester->getDisplay();
@ -65,7 +64,5 @@ class GetTagVisitsCommandTest extends TestCase
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
@ -20,33 +19,36 @@ class ListTagsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal()));
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService));
}
/** @test */
public function noTagsPrintsEmptyMessage(): void
{
$tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([])));
$this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([])),
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('No tags found', $output);
$tagsInfo->shouldHaveBeenCalled();
}
/** @test */
public function listOfTagsIsPrinted(): void
{
$tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([
new TagInfo('foo', 10, 2),
new TagInfo('bar', 7, 32),
])));
$this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([
new TagInfo('foo', 10, 2),
new TagInfo('bar', 7, 32),
])),
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@ -63,6 +65,5 @@ class ListTagsCommandTest extends TestCase
OUTPUT,
$output,
);
$tagsInfo->shouldHaveBeenCalled();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
@ -19,12 +19,12 @@ class RenameTagCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal()));
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService));
}
/** @test */
@ -32,9 +32,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow(
TagNotFoundException::fromTag('foo'),
);
$this->tagService->expects($this->once())->method('renameTag')->with(
TagRenaming::fromNames($oldName, $newName),
)->willThrowException(TagNotFoundException::fromTag('foo'));
$this->commandTester->execute([
'oldName' => $oldName,
@ -43,7 +43,6 @@ class RenameTagCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Tag with name "foo" could not be found', $output);
$renameTag->shouldHaveBeenCalled();
}
/** @test */
@ -51,9 +50,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn(
new Tag($newName),
);
$this->tagService->expects($this->once())->method('renameTag')->with(
TagRenaming::fromNames($oldName, $newName),
)->willReturn(new Tag($newName));
$this->commandTester->execute([
'oldName' => $oldName,
@ -62,6 +61,5 @@ class RenameTagCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Tag properly renamed', $output);
$renameTag->shouldHaveBeenCalled();
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
@ -22,12 +21,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $dbUpdater;
private MockObject & GeolocationDbUpdaterInterface $dbUpdater;
protected function setUp(): void
{
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater->reveal()));
$this->dbUpdater = $this->createMock(GeolocationDbUpdaterInterface::class);
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater));
}
/**
@ -39,10 +38,8 @@ class DownloadGeoLiteDbCommandTest extends TestCase
string $expectedMessage,
int $expectedExitCode,
): void {
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists): void {
[$beforeDownload, $handleProgress] = $args;
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback(
function (callable $beforeDownload, callable $handleProgress) use ($olderDbExists): void {
$beforeDownload($olderDbExists);
$handleProgress(100, 50);
@ -62,7 +59,6 @@ class DownloadGeoLiteDbCommandTest extends TestCase
);
self::assertStringContainsString($expectedMessage, $output);
self::assertSame($expectedExitCode, $exitCode);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideFailureParams(): iterable
@ -85,7 +81,9 @@ class DownloadGeoLiteDbCommandTest extends TestCase
*/
public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void
{
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will($checkUpdateBehavior);
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback(
$checkUpdateBehavior,
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@ -93,16 +91,13 @@ class DownloadGeoLiteDbCommandTest extends TestCase
self::assertStringContainsString($expectedMessage, $output);
self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideSuccessParams(): iterable
{
yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.'];
yield 'outdated db' => [function (array $args): GeolocationResult {
[$beforeDownload] = $args;
yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult {
$beforeDownload(true);
return GeolocationResult::DB_CREATED;
}, '[OK] GeoLite2 db file properly downloaded.'];
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -25,16 +24,16 @@ class GetNonOrphanVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier),
);
}
@ -45,10 +44,10 @@ class GetNonOrphanVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn(
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@ -64,7 +63,5 @@ class GetNonOrphanVisitsCommandTest extends TestCase
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@ -23,12 +22,12 @@ class GetOrphanVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal()));
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper));
}
/** @test */
@ -37,7 +36,7 @@ class GetOrphanVisitsCommandTest extends TestCase
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn(
$this->visitsHelper->expects($this->once())->method('orphanVisits')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
@ -55,6 +54,5 @@ class GetOrphanVisitsCommandTest extends TestCase
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
@ -15,12 +14,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitGeolocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
@ -35,33 +35,24 @@ class LocateVisitsCommandTest extends TestCase
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitService;
private ObjectProphecy $visitToLocation;
private ObjectProphecy $lock;
private ObjectProphecy $downloadDbCommand;
private MockObject & VisitLocatorInterface $visitService;
private MockObject & VisitToLocationHelperInterface $visitToLocation;
private MockObject & Lock\LockInterface $lock;
private MockObject & Command $downloadDbCommand;
protected function setUp(): void
{
$this->visitService = $this->prophesize(VisitLocator::class);
$this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class);
$this->visitService = $this->createMock(VisitLocatorInterface::class);
$this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::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 {
});
$locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal());
$locker = $this->createMock(Lock\LockFactory::class);
$this->lock = $this->createMock(Lock\LockInterface::class);
$locker->method('createLock')->with($this->isType('string'), 600.0, false)->willReturn($this->lock);
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
$this->visitToLocation->reveal(),
$locker->reveal(),
);
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
$this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME);
$this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand->reveal());
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand);
}
/**
@ -79,14 +70,23 @@ class LocateVisitsCommandTest extends TestCase
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
$locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
$mockMethodBehavior,
);
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willReturn(
Location::emptyInstance(),
);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->visitService->expects($this->exactly($expectedUnlocatedCalls))
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($mockMethodBehavior);
$this->visitService->expects($this->exactly($expectedEmptyCalls))
->method('locateVisitsWithEmptyLocation')
->withAnyParameters()
->willReturnCallback($mockMethodBehavior);
$this->visitService->expects($this->exactly($expectedAllCalls))
->method('locateAllVisits')
->withAnyParameters()
->willReturnCallback($mockMethodBehavior);
$this->visitToLocation->expects(
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($args);
@ -98,12 +98,6 @@ class LocateVisitsCommandTest extends TestCase
} else {
self::assertStringNotContainsString('Continue at your own', $output);
}
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
$locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
$resolveIpLocation->shouldHaveBeenCalledTimes(
$expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
);
}
public function provideArgs(): iterable
@ -122,18 +116,19 @@ class LocateVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow($e);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($this->invokeHelperMethods($visit, $location));
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Processing IP', $output);
self::assertStringContainsString($message, $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
public function provideIgnoredAddresses(): iterable
@ -148,28 +143,26 @@ class LocateVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow(
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($this->invokeHelperMethods($visit, $location));
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException(
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('An error occurred while locating IP. Skipped', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
{
return function (array $args) use ($visit, $location): void {
/** @var VisitGeolocationHelperInterface $helper */
[$helper] = $args;
return static function (VisitGeolocationHelperInterface $helper) use ($visit, $location): void {
$helper->geolocateVisit($visit);
$helper->onVisitLocated($location, $visit);
};
@ -178,11 +171,11 @@ class LocateVisitsCommandTest extends TestCase
/** @test */
public function noActionIsPerformedIfLockIsAcquired(): void
{
$this->lock->acquire(false)->willReturn(false);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(false);
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
});
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any());
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->visitToLocation->expects($this->never())->method('resolveVisitLocation');
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@ -191,25 +184,27 @@ class LocateVisitsCommandTest extends TestCase
sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
$output,
);
$locateVisits->shouldNotHaveBeenCalled();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
/** @test */
public function showsProperMessageWhenGeoLiteUpdateFails(): void
{
$this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_FAILURE);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output);
$this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled();
}
/** @test */
public function providingAllFlagOnItsOwnDisplaysNotice(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester->execute(['--all' => true]);
$output = $this->commandTester->getDisplay();
@ -222,6 +217,8 @@ class LocateVisitsCommandTest extends TestCase
*/
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Execution aborted');

View file

@ -31,8 +31,8 @@ class ApplicationFactoryTest extends TestCase
'baz' => 'baz',
],
]);
$sm->setService('foo', $this->createCommandMock('foo')->reveal());
$sm->setService('bar', $this->createCommandMock('bar')->reveal());
$sm->setService('foo', $this->createCommandMock('foo'));
$sm->setService('bar', $this->createCommandMock('bar'));
$instance = ($this->factory)($sm);

View file

@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\CLI\GeoLite;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use PHPUnit\Framework\MockObject\MockObject;
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\GeoLite\GeolocationDbUpdater;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
@ -25,23 +23,16 @@ use function range;
class GeolocationDbUpdaterTest extends TestCase
{
use ProphecyTrait;
private GeolocationDbUpdater $geolocationDbUpdater;
private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader;
private ObjectProphecy $lock;
private MockObject & DbUpdaterInterface $dbUpdater;
private MockObject & Reader $geoLiteDbReader;
private MockObject & Lock\LockInterface $lock;
protected function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->trackingOptions = new TrackingOptions();
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$this->dbUpdater = $this->createMock(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->createMock(Reader::class);
$this->lock = $this->createMock(Lock\LockInterface::class);
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
}
/** @test */
@ -50,25 +41,21 @@ class GeolocationDbUpdaterTest extends TestCase
$mustBeUpdated = fn () => self::assertTrue(true);
$prev = new DbUpdateException('');
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
$getMeta = $this->geoLiteDbReader->metadata();
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
$this->isNull(),
)->willThrowException($prev);
$this->geoLiteDbReader->expects($this->never())->method('metadata');
try {
$this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
self::assertTrue(false); // If this is reached, the test will fail
self::fail();
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertFalse($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldNotHaveBeenCalled();
$download->shouldHaveBeenCalledOnce();
$this->lock->acquire(true)->shouldHaveBeenCalledOnce();
$this->lock->release()->shouldHaveBeenCalledOnce();
}
/**
@ -77,26 +64,24 @@ class GeolocationDbUpdaterTest extends TestCase
*/
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch(
Chronos::now()->subDays($days)->getTimestamp(),
));
$prev = new DbUpdateException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
$this->isNull(),
)->willThrowException($prev);
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
$this->buildMetaWithBuildEpoch(Chronos::now()->subDays($days)->getTimestamp()),
);
try {
$this->geolocationDbUpdater()->checkDbUpdate();
self::assertTrue(false); // If this is reached, the test will fail
self::fail();
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertTrue($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldHaveBeenCalledOnce();
}
public function provideBigDays(): iterable
@ -113,17 +98,15 @@ class GeolocationDbUpdaterTest extends TestCase
*/
public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
});
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
$this->buildMetaWithBuildEpoch($buildEpoch),
);
$result = $this->geolocationDbUpdater()->checkDbUpdate();
self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result);
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldNotHaveBeenCalled();
}
public function provideSmallDays(): iterable
@ -139,18 +122,16 @@ class GeolocationDbUpdaterTest extends TestCase
/** @test */
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid'));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
});
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
$this->buildMetaWithBuildEpoch('invalid'),
);
$this->expectException(GeolocationDbUpdateFailedException::class);
$this->expectExceptionMessage(
'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
);
$fileExists->shouldBeCalledOnce();
$getMeta->shouldBeCalledOnce();
$download->shouldNotBeCalled();
$this->geolocationDbUpdater()->checkDbUpdate();
}
@ -177,10 +158,10 @@ class GeolocationDbUpdaterTest extends TestCase
public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void
{
$result = $this->geolocationDbUpdater($options)->checkDbUpdate();
$this->dbUpdater->expects($this->never())->method('databaseFileExists');
$this->geoLiteDbReader->expects($this->never())->method('metadata');
self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result);
$this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled();
$this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideTrackingOptions(): iterable
@ -192,13 +173,13 @@ class GeolocationDbUpdaterTest extends TestCase
private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater
{
$locker = $this->prophesize(Lock\LockFactory::class);
$locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$locker = $this->createMock(Lock\LockFactory::class);
$locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock);
return new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(),
$locker->reveal(),
$this->dbUpdater,
$this->geoLiteDbReader,
$locker,
$options ?? new TrackingOptions(),
);
}

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Util\ProcessRunner;
use Symfony\Component\Console\Helper\DebugFormatterHelper;
use Symfony\Component\Console\Helper\HelperSet;
@ -17,90 +15,73 @@ use Symfony\Component\Process\Process;
class ProcessRunnerTest extends TestCase
{
use ProphecyTrait;
private ProcessRunner $runner;
private ObjectProphecy $helper;
private ObjectProphecy $formatter;
private ObjectProphecy $process;
private ObjectProphecy $output;
private MockObject & ProcessHelper $helper;
private MockObject & DebugFormatterHelper $formatter;
private MockObject & Process $process;
private MockObject & OutputInterface $output;
protected function setUp(): void
{
$this->helper = $this->prophesize(ProcessHelper::class);
$this->formatter = $this->prophesize(DebugFormatterHelper::class);
$helperSet = $this->prophesize(HelperSet::class);
$helperSet->get('debug_formatter')->willReturn($this->formatter->reveal());
$this->helper->getHelperSet()->willReturn($helperSet->reveal());
$this->process = $this->prophesize(Process::class);
$this->helper = $this->createMock(ProcessHelper::class);
$this->formatter = $this->createMock(DebugFormatterHelper::class);
$helperSet = $this->createMock(HelperSet::class);
$helperSet->method('get')->with('debug_formatter')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->with()->willReturn($helperSet);
$this->process = $this->createMock(Process::class);
$this->output = $this->createMock(OutputInterface::class);
$this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal());
$this->output = $this->prophesize(OutputInterface::class);
$this->runner = new ProcessRunner($this->helper, fn () => $this->process);
}
/** @test */
public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
$isDebug = $this->output->isDebug()->willReturn(false);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false);
$this->output->expects($this->once())->method('isDebug')->with()->willReturn(false);
$this->output->expects($this->never())->method('write');
$this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf();
$this->process->expects($this->never())->method('isSuccessful');
$this->process->expects($this->never())->method('getCommandLine');
$this->helper->expects($this->never())->method('wrapCallback');
$this->formatter->expects($this->never())->method('start');
$this->formatter->expects($this->never())->method('stop');
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
$this->runner->run($this->output, []);
}
/** @test */
public function someMessagesAreWrittenWhenOutputIsVerbose(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true);
$isDebug = $this->output->isDebug()->willReturn(false);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$isSuccessful = $this->process->isSuccessful()->willReturn(true);
$getCommandLine = $this->process->getCommandLine()->willReturn('true');
$start = $this->formatter->start(Argument::cetera())->willReturn('');
$stop = $this->formatter->stop(Argument::cetera())->willReturn('');
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(true);
$this->output->expects($this->once())->method('isDebug')->with()->willReturn(false);
$this->output->expects($this->exactly(2))->method('write')->withAnyParameters();
$this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf();
$this->process->expects($this->exactly(2))->method('isSuccessful')->with()->willReturn(true);
$this->process->expects($this->once())->method('getCommandLine')->with()->willReturn('true');
$this->formatter->expects($this->once())->method('start')->withAnyParameters()->willReturn('');
$this->formatter->expects($this->once())->method('stop')->withAnyParameters()->willReturn('');
$this->helper->expects($this->never())->method('wrapCallback');
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2);
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
$isSuccessful->shouldHaveBeenCalledTimes(2);
$getCommandLine->shouldHaveBeenCalledOnce();
$start->shouldHaveBeenCalledOnce();
$stop->shouldHaveBeenCalledOnce();
$this->runner->run($this->output, []);
}
/** @test */
public function wrapsCallbackWhenOutputIsDebug(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
$isDebug = $this->output->isDebug()->willReturn(true);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void {
});
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false);
$this->output->expects($this->once())->method('isDebug')->with()->willReturn(true);
$this->output->expects($this->never())->method('write');
$this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf();
$this->process->expects($this->never())->method('isSuccessful');
$this->process->expects($this->never())->method('getCommandLine');
$this->helper->expects($this->once())->method('wrapCallback')->withAnyParameters()->willReturn(
function (): void {
},
);
$this->formatter->expects($this->never())->method('start');
$this->formatter->expects($this->never())->method('stop');
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$wrapCallback->shouldHaveBeenCalledOnce();
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
$this->runner->run($this->output, []);
}
}

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Symfony\Component\Console\Helper\Table;
@ -16,15 +14,13 @@ use Symfony\Component\Console\Output\OutputInterface;
class ShlinkTableTest extends TestCase
{
use ProphecyTrait;
private ShlinkTable $shlinkTable;
private ObjectProphecy $baseTable;
private MockObject & Table $baseTable;
protected function setUp(): void
{
$this->baseTable = $this->prophesize(Table::class);
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal());
$this->baseTable = $this->createMock(Table::class);
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable);
}
/** @test */
@ -35,29 +31,22 @@ class ShlinkTableTest extends TestCase
$headerTitle = 'Header';
$footerTitle = 'Footer';
$setStyle = $this->baseTable->setStyle(Argument::type(TableStyle::class))->willReturn(
$this->baseTable->reveal(),
);
$setHeaders = $this->baseTable->setHeaders($headers)->willReturn($this->baseTable->reveal());
$setRows = $this->baseTable->setRows($rows)->willReturn($this->baseTable->reveal());
$setFooterTitle = $this->baseTable->setFooterTitle($footerTitle)->willReturn($this->baseTable->reveal());
$setHeaderTitle = $this->baseTable->setHeaderTitle($headerTitle)->willReturn($this->baseTable->reveal());
$render = $this->baseTable->render()->willReturn($this->baseTable->reveal());
$this->baseTable->expects($this->once())->method('setStyle')->with(
$this->isInstanceOf(TableStyle::class),
)->willReturnSelf();
$this->baseTable->expects($this->once())->method('setHeaders')->with($headers)->willReturnSelf();
$this->baseTable->expects($this->once())->method('setRows')->with($rows)->willReturnSelf();
$this->baseTable->expects($this->once())->method('setFooterTitle')->with($footerTitle)->willReturnSelf();
$this->baseTable->expects($this->once())->method('setHeaderTitle')->with($headerTitle)->willReturnSelf();
$this->baseTable->expects($this->once())->method('render')->with()->willReturnSelf();
$this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle);
$setStyle->shouldHaveBeenCalledOnce();
$setHeaders->shouldHaveBeenCalledOnce();
$setRows->shouldHaveBeenCalledOnce();
$setFooterTitle->shouldHaveBeenCalledOnce();
$setHeaderTitle->shouldHaveBeenCalledOnce();
$render->shouldHaveBeenCalledOnce();
}
/** @test */
public function newTableIsCreatedForFactoryMethod(): void
{
$instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal());
$instance = ShlinkTable::default($this->createMock(OutputInterface::class));
$ref = new ReflectionObject($instance);
$baseTable = $ref->getProperty('baseTable');

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@ -34,6 +35,7 @@ return [
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class,
ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class,
@ -44,6 +46,14 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class,
ShortUrl\Repository\ShortUrlListRepository::class => [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
],
ShortUrl\Repository\CrawlableShortCodesQuery::class => [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
],
Tag\TagService::class => ConfigAbstractFactory::class,
@ -55,6 +65,10 @@ return [
Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
Visit\Repository\VisitLocationRepository::class => [
EntityRepositoryFactory::class,
Visit\Entity\Visit::class,
],
Util\UrlValidator::class => ConfigAbstractFactory::class,
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
@ -109,7 +123,11 @@ return [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
],
Visit\Geolocation\VisitLocator::class => ['em'],
ShortUrl\ShortUrlListService::class => [
ShortUrl\Repository\ShortUrlListRepository::class,
Options\UrlShortenerOptions::class,
],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitLocationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosInterface;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
@ -35,7 +36,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
}
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
@ -46,7 +47,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
/**
* @return ($date is null ? null : Chronos)
*/
function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $date): ?Chronos
{
$parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date,
@ -57,6 +61,11 @@ function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
return $parsedDate?->setTimezone(date_default_timezone_get());
}
function normalizeDate(string|DateTimeInterface|ChronosInterface $date): Chronos
{
return normalizeOptionalDate($date);
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
@ -69,6 +78,12 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN
return $value !== null ? (bool) $value : null;
}
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
{
$value = $inputFilter->getValue($fieldName);
return empty($value) ? null : $value;
}
function arrayToString(array $array, int $indentSize = 4): string
{
$indent = str_repeat(' ', $indentSize);

View file

@ -17,7 +17,7 @@ use const PHP_EOL;
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
{
public function __construct(private CrawlingHelperInterface $crawlingHelper)
public function __construct(private readonly CrawlingHelperInterface $crawlingHelper)
{
}

View file

@ -4,20 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Crawling;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface;
class CrawlingHelper implements CrawlingHelperInterface
{
public function __construct(private EntityManagerInterface $em)
public function __construct(private readonly CrawlableShortCodesQueryInterface $query)
{
}
public function listCrawlableShortCodes(): iterable
{
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
yield from $repo->findCrawlableShortCodes();
yield from ($this->query)();
}
}

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Crawling;
interface CrawlingHelperInterface
{
/**
* @return string[]|iterable
* @return iterable<string>
*/
public function listCrawlableShortCodes(): iterable;
}

View file

@ -11,39 +11,53 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportResult;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\StyleInterface;
use Throwable;
use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
class ImportedLinksProcessor implements ImportedLinksProcessorInterface
{
private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ShortUrlRelationResolverInterface $relationResolver,
private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper,
private readonly DoctrineBatchHelperInterface $batchHelper,
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
}
public function process(StyleInterface $io, ImportResult $result, ImportParams $params): void
{
$io->title('Importing short URLs');
$this->importShortUrls($io, $result->shlinkUrls, $params);
if ($params->importOrphanVisits) {
$io->title('Importing orphan visits');
$this->importOrphanVisits($io, $result->orphanVisits);
}
$io->success('Data properly imported!');
}
/**
* @param iterable<ImportedShlinkUrl> $shlinkUrls
*/
public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
private function importShortUrls(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
{
$importShortCodes = $params->importShortCodes;
$source = $params->source;
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100);
/** @var ImportedShlinkUrl $importedUrl */
foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
@ -78,7 +92,9 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
bool $importShortCodes,
callable $skipOnShortCodeConflict,
): ShortUrlImporting {
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$alreadyImportedShortUrl = $shortUrlRepo->findOneByImportedUrl($importedUrl);
if ($alreadyImportedShortUrl !== null) {
return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl);
}
@ -107,4 +123,29 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false);
}
/**
* @param iterable<ImportedShlinkOrphanVisit> $orphanVisits
*/
private function importOrphanVisits(StyleInterface $io, iterable $orphanVisits): void
{
$iterable = $this->batchHelper->wrapIterable($orphanVisits, 100);
/** @var VisitRepositoryInterface $visitRepo */
$visitRepo = $this->em->getRepository(Visit::class);
$mostRecentOrphanVisit = $visitRepo->findMostRecentOrphanVisit();
$importedVisits = 0;
foreach ($iterable as $importedOrphanVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentOrphanVisit?->getDate()->gte(normalizeDate($importedOrphanVisit->date))) {
continue;
}
$this->em->persist(Visit::fromOrphanImport($importedOrphanVisit));
$importedVisits++;
}
$io->text(sprintf('<info>Imported %s</info> orphan visits.', $importedVisits));
}
}

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Importer;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
final class ShortUrlImporting
@ -38,7 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date))) {
if ($mostRecentImportedDate?->gte(normalizeDate($importedVisit->date))) {
continue;
}

View file

@ -25,15 +25,17 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function count;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
class ShortUrl extends AbstractEntity
{
private string $longUrl;
private string $shortCode;
private Chronos $dateCreated;
/** @var Collection|Visit[] */
/** @var Collection<int, Visit> */
private Collection $visits;
/** @var Collection|Tag[] */
/** @var Collection<int, Tag> */
private Collection $tags;
private ?Chronos $validSince = null;
private ?Chronos $validUntil = null;
@ -55,37 +57,37 @@ class ShortUrl extends AbstractEntity
public static function createEmpty(): self
{
return self::fromMeta(ShortUrlCreation::createEmpty());
return self::create(ShortUrlCreation::createEmpty());
}
public static function withLongUrl(string $longUrl): self
{
return self::fromMeta(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
}
public static function fromMeta(
ShortUrlCreation $meta,
public static function create(
ShortUrlCreation $creation,
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$instance = new self();
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$instance->longUrl = $meta->getLongUrl();
$instance->longUrl = $creation->getLongUrl();
$instance->dateCreated = Chronos::now();
$instance->visits = new ArrayCollection();
$instance->tags = $relationResolver->resolveTags($meta->getTags());
$instance->validSince = $meta->getValidSince();
$instance->validUntil = $meta->getValidUntil();
$instance->maxVisits = $meta->getMaxVisits();
$instance->customSlugWasProvided = $meta->hasCustomSlug();
$instance->shortCodeLength = $meta->getShortCodeLength();
$instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength);
$instance->domain = $relationResolver->resolveDomain($meta->getDomain());
$instance->authorApiKey = $meta->getApiKey();
$instance->title = $meta->getTitle();
$instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
$instance->crawlable = $meta->isCrawlable();
$instance->forwardQuery = $meta->forwardQuery();
$instance->tags = $relationResolver->resolveTags($creation->getTags());
$instance->validSince = $creation->getValidSince();
$instance->validUntil = $creation->getValidUntil();
$instance->maxVisits = $creation->getMaxVisits();
$instance->customSlugWasProvided = $creation->hasCustomSlug();
$instance->shortCodeLength = $creation->getShortCodeLength();
$instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength);
$instance->domain = $relationResolver->resolveDomain($creation->getDomain());
$instance->authorApiKey = $creation->getApiKey();
$instance->title = $creation->getTitle();
$instance->titleWasAutoResolved = $creation->titleWasAutoResolved();
$instance->crawlable = $creation->isCrawlable();
$instance->forwardQuery = $creation->forwardQuery();
return $instance;
}
@ -107,21 +109,13 @@ class ShortUrl extends AbstractEntity
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode;
}
$instance = self::fromMeta(ShortUrlCreation::fromRawData($meta), $relationResolver);
$validSince = $url->meta->validSince;
if ($validSince !== null) {
$instance->validSince = Chronos::instance($validSince);
}
$validUntil = $url->meta->validUntil;
if ($validUntil !== null) {
$instance->validUntil = Chronos::instance($validUntil);
}
$instance = self::create(ShortUrlCreation::fromRawData($meta), $relationResolver);
$instance->importSource = $url->source->value;
$instance->importOriginalShortCode = $url->shortCode;
$instance->dateCreated = Chronos::instance($url->createdAt);
$instance->validSince = normalizeOptionalDate($url->meta->validSince);
$instance->validUntil = normalizeOptionalDate($url->meta->validUntil);
$instance->dateCreated = normalizeDate($url->createdAt);
return $instance;
}
@ -147,7 +141,7 @@ class ShortUrl extends AbstractEntity
}
/**
* @return Collection|Tag[]
* @return Collection<int, Tag>
*/
public function getTags(): Collection
{
@ -174,6 +168,12 @@ class ShortUrl extends AbstractEntity
return count($this->visits);
}
public function nonBotVisitsCount(): int
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('potentialBot', false));
return count($this->visits->matching($criteria));
}
public function mostRecentImportedVisitDate(): ?Chronos
{
/** @var Selectable $visits */
@ -189,7 +189,7 @@ class ShortUrl extends AbstractEntity
}
/**
* @param Collection|Visit[] $visits
* @param Collection<int, Visit> $visits
* @internal
*/
public function setVisits(Collection $visits): self

View file

@ -0,0 +1,37 @@
<?php
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\contains;
use function Functional\map;
enum OrderableField: string
{
case LONG_URL = 'longUrl';
case SHORT_CODE = 'shortCode';
case DATE_CREATED = 'dateCreated';
case TITLE = 'title';
case VISITS = 'visits';
case NON_BOT_VISITS = 'nonBotVisits';
/**
* @return string[]
*/
public static function values(): array
{
return map(self::cases(), static fn (OrderableField $field) => $field->value);
}
public static function isBasicField(string $value): bool
{
return contains(
[self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value],
$value,
);
}
public static function isVisitsField(string $value): bool
{
return $value === self::VISITS->value || $value === self::NON_BOT_VISITS->value;
}
}

View file

@ -10,9 +10,10 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -68,13 +69,13 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
}
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
$this->domain = $inputFilter->getValue(ShortUrlInputFilter::DOMAIN);
$this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN);
$this->shortCodeLength = getOptionalIntFromInputFilter(
$inputFilter,
ShortUrlInputFilter::SHORT_CODE_LENGTH,

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface
{
@ -69,8 +69,8 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);

View file

@ -10,23 +10,23 @@ use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlsParams
{
public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits'];
public const DEFAULT_ITEMS_PER_PAGE = 10;
private int $page;
private int $itemsPerPage;
private ?string $searchTerm;
private array $tags;
private TagsMode $tagsMode = TagsMode::ANY;
private Ordering $orderBy;
private ?DateRange $dateRange;
private function __construct()
{
private function __construct(
public readonly int $page,
public readonly int $itemsPerPage,
public readonly ?string $searchTerm,
public readonly array $tags,
public readonly Ordering $orderBy,
public readonly ?DateRange $dateRange,
public readonly bool $excludeMaxVisitsReached,
public readonly bool $excludePastValidUntil,
public readonly TagsMode $tagsMode = TagsMode::ANY,
) {
}
public static function emptyInstance(): self
@ -38,38 +38,31 @@ final class ShortUrlsParams
* @throws ValidationException
*/
public static function fromRawData(array $query): self
{
$instance = new self();
$instance->validateAndInit($query);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $query): void
{
$inputFilter = new ShortUrlsParamsInputFilter($query);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = buildDateRange(
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
return new self(
page: (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1),
itemsPerPage: (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
),
searchTerm: $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM),
tags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS),
orderBy: Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)),
dateRange: buildDateRange(
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
),
excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED),
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
);
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
);
$this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE));
}
private function resolveTagsMode(?string $rawTagsMode): TagsMode
private static function resolveTagsMode(?string $rawTagsMode): TagsMode
{
if ($rawTagsMode === null) {
return TagsMode::ANY;
@ -77,39 +70,4 @@ final class ShortUrlsParams
return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY;
}
public function page(): int
{
return $this->page;
}
public function itemsPerPage(): int
{
return $this->itemsPerPage;
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function orderBy(): Ordering
{
return $this->orderBy;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
public function tagsMode(): TagsMode
{
return $this->tagsMode;
}
}

View file

@ -4,8 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\map;
enum TagsMode: string
{
case ANY = 'any';
case ALL = 'all';
public static function values(): array
{
return map(self::cases(), static fn (TagsMode $mode) => $mode->value);
}
}

View file

@ -8,7 +8,7 @@ use Laminas\InputFilter\InputFilter;
use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
class ShortUrlsParamsInputFilter extends InputFilter
@ -23,6 +23,8 @@ class ShortUrlsParamsInputFilter extends InputFilter
public const ITEMS_PER_PAGE = 'itemsPerPage';
public const TAGS_MODE = 'tagsMode';
public const ORDER_BY = 'orderBy';
public const EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
public const EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
public function __construct(array $data)
{
@ -44,11 +46,14 @@ class ShortUrlsParamsInputFilter extends InputFilter
$tagsMode = $this->createInput(self::TAGS_MODE, false);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => [TagsMode::ALL->value, TagsMode::ANY->value],
'haystack' => TagsMode::values(),
'strict' => InArray::COMPARE_STRICT,
]));
$this->add($tagsMode);
$this->add($this->createOrderByInput(self::ORDER_BY, ShortUrlsParams::ORDERABLE_FIELDS));
$this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values()));
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false));
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false));
}
}

View file

@ -8,27 +8,34 @@ use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
public function __construct(
private ShortUrlRepositoryInterface $repository,
private ShortUrlsParams $params,
private ?ApiKey $apiKey,
private readonly ShortUrlListRepositoryInterface $repository,
private readonly ShortUrlsParams $params,
private readonly ?ApiKey $apiKey,
private readonly string $defaultDomain,
) {
}
public function getSlice(int $offset, int $length): iterable
{
return $this->repository->findList(
ShortUrlsListFiltering::fromLimitsAndParams($length, $offset, $this->params, $this->apiKey),
);
return $this->repository->findList(ShortUrlsListFiltering::fromLimitsAndParams(
$length,
$offset,
$this->params,
$this->apiKey,
$this->defaultDomain,
));
}
public function getNbResults(): int
{
return $this->repository->countList(ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey));
return $this->repository->countList(
ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey, $this->defaultDomain),
);
}
}

View file

@ -9,44 +9,40 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function str_contains;
use function strtolower;
class ShortUrlsCountFiltering
{
public readonly bool $searchIncludesDefaultDomain;
public function __construct(
private ?string $searchTerm = null,
private array $tags = [],
private ?TagsMode $tagsMode = null,
private ?DateRange $dateRange = null,
private ?ApiKey $apiKey = null,
public readonly ?string $searchTerm = null,
public readonly array $tags = [],
public readonly ?TagsMode $tagsMode = null,
public readonly ?DateRange $dateRange = null,
public readonly bool $excludeMaxVisitsReached = false,
public readonly bool $excludePastValidUntil = false,
public readonly ?ApiKey $apiKey = null,
?string $defaultDomain = null,
) {
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
strtolower($defaultDomain),
strtolower($searchTerm),
);
}
public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey): self
public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, string $defaultDomain): self
{
return new self($params->searchTerm(), $params->tags(), $params->tagsMode(), $params->dateRange(), $apiKey);
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function tagsMode(): ?TagsMode
{
return $this->tagsMode;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
public function apiKey(): ?ApiKey
{
return $this->apiKey;
return new self(
$params->searchTerm,
$params->tags,
$params->tagsMode,
$params->dateRange,
$params->excludeMaxVisitsReached,
$params->excludePastValidUntil,
$apiKey,
$defaultDomain,
);
}
}

View file

@ -13,44 +13,49 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlsListFiltering extends ShortUrlsCountFiltering
{
public function __construct(
private ?int $limit,
private ?int $offset,
private Ordering $orderBy,
public readonly ?int $limit,
public readonly ?int $offset,
public readonly Ordering $orderBy,
?string $searchTerm = null,
array $tags = [],
?TagsMode $tagsMode = null,
?DateRange $dateRange = null,
bool $excludeMaxVisitsReached = false,
bool $excludePastValidUntil = false,
?ApiKey $apiKey = null,
?string $defaultDomain = null,
) {
parent::__construct($searchTerm, $tags, $tagsMode, $dateRange, $apiKey);
}
public static function fromLimitsAndParams(int $limit, int $offset, ShortUrlsParams $params, ?ApiKey $apiKey): self
{
return new self(
$limit,
$offset,
$params->orderBy(),
$params->searchTerm(),
$params->tags(),
$params->tagsMode(),
$params->dateRange(),
parent::__construct(
$searchTerm,
$tags,
$tagsMode,
$dateRange,
$excludeMaxVisitsReached,
$excludePastValidUntil,
$apiKey,
$defaultDomain,
);
}
public function offset(): ?int
{
return $this->offset;
}
public function limit(): ?int
{
return $this->limit;
}
public function orderBy(): Ordering
{
return $this->orderBy;
public static function fromLimitsAndParams(
int $limit,
int $offset,
ShortUrlsParams $params,
?ApiKey $apiKey,
string $defaultDomain,
): self {
return new self(
$limit,
$offset,
$params->orderBy,
$params->searchTerm,
$params->tags,
$params->tagsMode,
$params->dateRange,
$params->excludeMaxVisitsReached,
$params->excludePastValidUntil,
$apiKey,
$defaultDomain,
);
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
class CrawlableShortCodesQuery extends EntitySpecificationRepository implements CrawlableShortCodesQueryInterface
{
/**
* @return iterable<string>
*/
public function __invoke(): iterable
{
$blockSize = 1000;
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('DISTINCT s.shortCode')
->from(ShortUrl::class, 's')
->where($qb->expr()->eq('s.crawlable', ':crawlable'))
->setParameter('crawlable', true)
->setMaxResults($blockSize);
$page = 0;
do {
$qbClone = (clone $qb)->setFirstResult($blockSize * $page);
$iterator = $qbClone->getQuery()->toIterable();
$resultsFound = false;
$page++;
foreach ($iterator as ['shortCode' => $shortCode]) {
$resultsFound = true;
yield $shortCode;
}
} while ($resultsFound);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
interface CrawlableShortCodesQueryInterface
{
/**
* @return iterable<string>
*/
public function __invoke(): iterable;
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
use Cake\Chronos\Chronos;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use function array_column;
use function sprintf;
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
{
/**
* @return ShortUrl[]
*/
public function findList(ShortUrlsListFiltering $filtering): array
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s')
->setMaxResults($filtering->limit)
->setFirstResult($filtering->offset);
$this->processOrderByForList($qb, $filtering);
$result = $qb->getQuery()->getResult();
if (OrderableField::isVisitsField($filtering->orderBy->field ?? '')) {
return array_column($result, 0);
}
return $result;
}
private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void
{
// With no explicit order by, fallback to dateCreated-DESC
$fieldName = $filtering->orderBy->field;
if ($fieldName === null) {
$qb->orderBy('s.dateCreated', 'DESC');
return;
}
$order = $filtering->orderBy->direction;
if (OrderableField::isBasicField($fieldName)) {
$qb->orderBy('s.' . $fieldName, $order);
} elseif (OrderableField::isVisitsField($fieldName)) {
// FIXME This query is inefficient.
// Diagnostic: It might need to use a sub-query, as done with the tags list query.
$qb->addSelect('COUNT(DISTINCT v)')
->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(
$qb->expr()->eq('v.shortUrl', 's'),
$fieldName === OrderableField::NON_BOT_VISITS->value
? $qb->expr()->eq('v.potentialBot', 'false')
: null,
))
->groupBy('s')
->orderBy('COUNT(DISTINCT v)', $order);
}
}
public function countList(ShortUrlsCountFiltering $filtering): int
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
->where('1=1');
$dateRange = $filtering->dateRange;
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->endDate !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
$searchTerm = $filtering->searchTerm;
$tags = $filtering->tags;
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}
// Apply general search conditions
$conditions = [
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
];
// Include default domain in search if provided
if ($filtering->searchIncludesDefaultDomain) {
$conditions[] = $qb->expr()->isNull('s.domain');
}
// Apply tag conditions, only when not filtering by all provided tags
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
if (empty($tags) || $tagsMode === TagsMode::ANY) {
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
}
$qb->leftJoin('s.domain', 'd')
->andWhere($qb->expr()->orX(...$conditions))
->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
if (! empty($tags)) {
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
$tagsMode === TagsMode::ANY
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
: $this->joinAllTags($qb, $tags);
}
if ($filtering->excludeMaxVisitsReached) {
$qb->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.maxVisits'),
$qb->expr()->gt(
's.maxVisits',
sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class),
),
));
}
if ($filtering->excludePastValidUntil) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.validUntil'),
$qb->expr()->gte('s.validUntil', ':minValidUntil'),
))
->setParameter('minValidUntil', Chronos::now()->toDateTimeString());
}
$this->applySpecification($qb, $filtering->apiKey?->spec(), 's');
return $qb;
}
private function joinAllTags(QueryBuilder $qb, array $tags): void
{
foreach ($tags as $index => $tag) {
$alias = 't_' . $index;
$qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
->setParameter('tag' . $index, $tag);
}
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
interface ShortUrlListRepositoryInterface
{
/**
* @return ShortUrl[]
*/
public function findList(ShortUrlsListFiltering $filtering): array;
public function countList(ShortUrlsCountFiltering $filtering): int;
}

View file

@ -11,129 +11,15 @@ use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function array_column;
use function count;
use function Functional\contains;
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
{
/**
* @return ShortUrl[]
*/
public function findList(ShortUrlsListFiltering $filtering): array
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s')
->setMaxResults($filtering->limit())
->setFirstResult($filtering->offset());
// In case the ordering has been specified, the query could be more complex. Process it
if ($filtering->orderBy()->hasOrderField()) {
return $this->processOrderByForList($qb, $filtering->orderBy());
}
// With no explicit order by, fallback to dateCreated-DESC
return $qb->orderBy('s.dateCreated', 'DESC')->getQuery()->getResult();
}
private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array
{
$fieldName = $orderBy->field;
$order = $orderBy->direction;
if ($fieldName === 'visits') {
// FIXME This query is inefficient.
// Diagnostic: It might need to use a sub-query, as done with the tags list query.
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return array_column($qb->getQuery()->getResult(), 0);
}
$orderableFields = ['longUrl', 'shortCode', 'dateCreated', 'title'];
if (contains($orderableFields, $fieldName)) {
$qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult();
}
public function countList(ShortUrlsCountFiltering $filtering): int
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
->where('1=1');
$dateRange = $filtering->dateRange();
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->endDate !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
$searchTerm = $filtering->searchTerm();
$tags = $filtering->tags();
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}
// Apply general search conditions
$conditions = [
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
];
// Apply tag conditions, only when not filtering by all provided tags
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
if (empty($tags) || $tagsMode === TagsMode::ANY) {
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
}
$qb->leftJoin('s.domain', 'd')
->andWhere($qb->expr()->orX(...$conditions))
->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
if (! empty($tags)) {
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
$tagsMode === TagsMode::ANY
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
: $this->joinAllTags($qb, $tags);
}
$this->applySpecification($qb, $filtering->apiKey()?->spec(), 's');
return $qb;
}
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl
{
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
@ -304,28 +190,4 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->andWhere($qb->expr()->isNull('s.domain'));
}
}
public function findCrawlableShortCodes(): iterable
{
$blockSize = 1000;
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('DISTINCT s.shortCode')
->from(ShortUrl::class, 's')
->where($qb->expr()->eq('s.crawlable', ':crawlable'))
->setParameter('crawlable', true)
->setMaxResults($blockSize);
$page = 0;
do {
$qbClone = (clone $qb)->setFirstResult($blockSize * $page);
$iterator = $qbClone->getQuery()->toIterable();
$resultsFound = false;
$page++;
foreach ($iterator as ['shortCode' => $shortCode]) {
$resultsFound = true;
yield $shortCode;
}
} while ($resultsFound);
}
}

View file

@ -10,16 +10,10 @@ use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function findList(ShortUrlsListFiltering $filtering): array;
public function countList(ShortUrlsCountFiltering $filtering): int;
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl;
public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl;
@ -31,6 +25,4 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl;
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl;
public function findCrawlableShortCodes(): iterable;
}

View file

@ -21,8 +21,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
/** @var array<string, Tag> */
private array $memoizedNewTags = [];
public function __construct(private EntityManagerInterface $em)
public function __construct(private readonly EntityManagerInterface $em)
{
// Registering this as an event listener will make the postFlush method to be called automatically
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
}
@ -61,7 +62,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag {
// Memoize only new tags, and let doctrine handle objects hydrated from persistence
$tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName);
$tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName);
$this->em->persist($tag);
return $tag;

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlListService implements ShortUrlListServiceInterface
{
public function __construct(
private readonly ShortUrlListRepositoryInterface $repo,
private readonly UrlShortenerOptions $urlShortenerOptions,
) {
}
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
{
$defaultDomain = $this->urlShortenerOptions->domain['hostname'] ?? '';
$paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain));
$paginator->setMaxPerPage($params->itemsPerPage)
->setCurrentPage($params->page);
return $paginator;
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlListServiceInterface
{
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
}

View file

@ -5,16 +5,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Doctrine\ORM;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -28,20 +24,6 @@ class ShortUrlService implements ShortUrlServiceInterface
) {
}
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey));
$paginator->setMaxPerPage($params->itemsPerPage())
->setCurrentPage($params->page());
return $paginator;
}
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException

View file

@ -4,22 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlServiceInterface
{
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException

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