mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-02 13:13:11 +08:00
Compare commits
79 Commits
v2.2.0-alp
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb6756d801 | ||
|
|
cf605407ad | ||
|
|
1a4eee1c81 | ||
|
|
4c5cd88041 | ||
|
|
4d346d1fea | ||
|
|
7f39e6d768 | ||
|
|
9b9de8e290 | ||
|
|
e1e3c7f061 | ||
|
|
3218f8c283 | ||
|
|
f0acce1be0 | ||
|
|
dd4b4277c9 | ||
|
|
baf77b6ffb | ||
|
|
5be882a31b | ||
|
|
ae060f3b13 | ||
|
|
e8ab664561 | ||
|
|
f4bf3551f6 | ||
|
|
8f06e4b20f | ||
|
|
bfdd6e0c50 | ||
|
|
ba13d99a71 | ||
|
|
eac468514b | ||
|
|
7da00fbc8c | ||
|
|
4b7c54d7a9 | ||
|
|
c336bb1901 | ||
|
|
fbb1c449da | ||
|
|
252cc7f49d | ||
|
|
00cac4ba72 | ||
|
|
91aaffc6db | ||
|
|
2e269bcacd | ||
|
|
bdd14427d9 | ||
|
|
06c59fe2dd | ||
|
|
9a78fd1a26 | ||
|
|
626c92460b | ||
|
|
7e0a14493e | ||
|
|
8d23e60d3a | ||
|
|
5f0293bc21 | ||
|
|
afe7381263 | ||
|
|
b75922f1d3 | ||
|
|
d9ae83a92b | ||
|
|
22cc9ace4d | ||
|
|
53a37feafe | ||
|
|
0cab51b01b | ||
|
|
5f258b6a28 | ||
|
|
cc41c51f77 | ||
|
|
5f42266cf2 | ||
|
|
522d8ed236 | ||
|
|
78359c28c7 | ||
|
|
13bb48d068 | ||
|
|
f6d9a83202 | ||
|
|
dfdae96da5 | ||
|
|
9f13063b1f | ||
|
|
1e8c36b5f1 | ||
|
|
e747a0b250 | ||
|
|
79b8834c61 | ||
|
|
313b6a59b9 | ||
|
|
d5288f756e | ||
|
|
867659ea25 | ||
|
|
74ad3553cb | ||
|
|
8b0ce8e6f3 | ||
|
|
0e4bccc4bb | ||
|
|
c4ae89a279 | ||
|
|
80d41db901 | ||
|
|
6c30fc73ee | ||
|
|
56932e4ea6 | ||
|
|
84b38c4940 | ||
|
|
aece9e68ba | ||
|
|
d067f52ac2 | ||
|
|
b5947d1642 | ||
|
|
3232ab401f | ||
|
|
1ef10f11cb | ||
|
|
5beaab85ac | ||
|
|
4498386f56 | ||
|
|
a30f796100 | ||
|
|
93a2c83652 | ||
|
|
4d4423413d | ||
|
|
a1c74c4038 | ||
|
|
f71bb5e307 | ||
|
|
9190996e54 | ||
|
|
af8b6b7f96 | ||
|
|
e775b0f12f |
@@ -8,8 +8,6 @@ php:
|
||||
- '7.4'
|
||||
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
- docker
|
||||
|
||||
cache:
|
||||
@@ -17,8 +15,10 @@ cache:
|
||||
- $HOME/.composer/cache/files
|
||||
|
||||
before_install:
|
||||
- sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
|
||||
- yes | pecl install pdo_sqlsrv swoole-4.4.18
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- yes | pecl install swoole-4.4.15
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
|
||||
install:
|
||||
@@ -26,8 +26,7 @@ install:
|
||||
- composer install --no-interaction --prefer-dist
|
||||
|
||||
before_script:
|
||||
- mysql -e 'CREATE DATABASE shlink_test;'
|
||||
- psql -c 'create database shlink_test;' -U postgres
|
||||
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- mkdir build
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
|
||||
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -4,7 +4,7 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
## 2.2.0 - 2020-05-09
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -19,7 +19,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
The updates are only published when serving Shlink with swoole.
|
||||
|
||||
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates.
|
||||
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subscribe to updates.
|
||||
|
||||
* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats.
|
||||
* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag.
|
||||
|
||||
It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag.
|
||||
|
||||
* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag.
|
||||
|
||||
Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag.
|
||||
|
||||
Also, the `tag:list` CLI command has been changed and it always behaves like this.
|
||||
|
||||
* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets.
|
||||
* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered.
|
||||
* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql.
|
||||
* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled.
|
||||
* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired.
|
||||
* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer.
|
||||
|
||||
|
||||
## 2.1.4 - 2020-04-30
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
@@ -35,9 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql.
|
||||
* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled.
|
||||
* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired.
|
||||
* [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits.
|
||||
|
||||
|
||||
## 2.1.3 - 2020-04-09
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,8 +1,8 @@
|
||||
FROM php:7.4.2-alpine3.11 as base
|
||||
FROM php:7.4.5-alpine3.11 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.0.5
|
||||
ARG SHLINK_VERSION=2.1.4
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
@@ -25,15 +25,12 @@ RUN \
|
||||
|
||||
# Install swoole and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
|
||||
# Install shlink
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^2.6",
|
||||
"mezzio/mezzio-swoole": "^2.6.4",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.0",
|
||||
"ocramius/proxy-manager": "^2.7.0",
|
||||
@@ -48,10 +48,10 @@
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0",
|
||||
"shlinkio/shlink-common": "^3.1.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "dev-master#f51a2186cf474fb5773b0ef74b8533878de9dd1e as 5.0.0",
|
||||
"shlinkio/shlink-installer": "^5.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
@@ -109,7 +109,7 @@
|
||||
],
|
||||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:ci",
|
||||
"@test:db",
|
||||
"@test:api:ci"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
@@ -121,11 +121,6 @@
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:ci": [
|
||||
"@test:db:sqlite",
|
||||
"@test:db:mysql",
|
||||
"@test:db:postgres"
|
||||
],
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||
@@ -152,8 +147,7 @@
|
||||
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
|
||||
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB and PostgreSQL</>",
|
||||
"test:db:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL and PostgreSQL</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
|
||||
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
|
||||
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
|
||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||
|
||||
@@ -35,6 +35,8 @@ return [
|
||||
Option\Mercure\MercurePublicUrlConfigOption::class,
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
||||
@@ -5,7 +5,8 @@ declare(strict_types=1);
|
||||
return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
'enable_coroutine' => true,
|
||||
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
|
||||
'enable_coroutine' => false,
|
||||
|
||||
'swoole-http-server' => [
|
||||
'host' => '0.0.0.0',
|
||||
|
||||
@@ -12,6 +12,7 @@ return [
|
||||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => false,
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ $buildDbConnection = function (): array {
|
||||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
$isCi = env('TRAVIS', false);
|
||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
|
||||
$driverConfigMap = [
|
||||
'sqlite' => [
|
||||
@@ -29,8 +30,9 @@ $buildDbConnection = function (): array {
|
||||
'mysql' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => $isCi ? '' : 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
@@ -41,8 +43,9 @@ $buildDbConnection = function (): array {
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
'port' => $isCi ? '5433' : '5432',
|
||||
'user' => 'postgres',
|
||||
'password' => $isCi ? '' : 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
@@ -50,7 +53,7 @@ $buildDbConnection = function (): array {
|
||||
'driver' => 'pdo_sqlsrv',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
|
||||
'user' => 'sa',
|
||||
'password' => $isCi ? '' : 'Passw0rd!',
|
||||
'password' => 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
|
||||
9
data/infra/ci/install-ms-odbc.sh
Executable file
9
data/infra/ci/install-ms-odbc.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql17
|
||||
apt-get install unixodbc-dev
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM php:7.4.2-fpm-alpine3.11
|
||||
FROM php:7.4.5-fpm-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
@@ -67,15 +67,12 @@ RUN rm /tmp/xdebug.tar.gz
|
||||
|
||||
# Install sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:7.4.2-alpine3.11
|
||||
FROM php:7.4.5-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -68,15 +68,12 @@ RUN rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
||||
14
data/infra/swoole_proxy_vhost.conf
Normal file
14
data/infra/swoole_proxy_vhost.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://shlink_swoole:8080;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
}
|
||||
27
data/migrations/Version20200503170404.php
Normal file
27
data/migrations/Version20200503170404.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20200503170404 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'IDX_visits_date';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
|
||||
$visits->addIndex(['date'], self::INDEX_NAME);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasIndex(self::INDEX_NAME));
|
||||
$visits->dropIndex(self::INDEX_NAME);
|
||||
}
|
||||
}
|
||||
14
docker-compose.ci.yml
Normal file
14
docker-compose.ci.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_db:
|
||||
environment:
|
||||
MYSQL_DATABASE: shlink_test
|
||||
|
||||
shlink_db_postgres:
|
||||
environment:
|
||||
POSTGRES_DB: shlink_test
|
||||
|
||||
shlink_db_maria:
|
||||
environment:
|
||||
MYSQL_DATABASE: shlink_test
|
||||
@@ -32,6 +32,17 @@ services:
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_swoole_proxy:
|
||||
container_name: shlink_swoole_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8002:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
links:
|
||||
- shlink_swoole
|
||||
|
||||
shlink_swoole:
|
||||
container_name: shlink_swoole
|
||||
build:
|
||||
@@ -68,7 +79,7 @@ services:
|
||||
|
||||
shlink_db_postgres:
|
||||
container_name: shlink_db_postgres
|
||||
image: postgres:10.7-alpine
|
||||
image: postgres:12.2-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
@@ -81,7 +92,7 @@ services:
|
||||
|
||||
shlink_db_maria:
|
||||
container_name: shlink_db_maria
|
||||
image: mariadb:10.2
|
||||
image: mariadb:10.5
|
||||
ports:
|
||||
- "3308:3306"
|
||||
volumes:
|
||||
@@ -103,7 +114,7 @@ services:
|
||||
|
||||
shlink_redis:
|
||||
container_name: shlink_redis
|
||||
image: redis:5.0-alpine
|
||||
image: redis:6.0-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
|
||||
@@ -120,7 +131,7 @@ services:
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.8
|
||||
image: dunglas/mercure:v0.9
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
|
||||
@@ -18,7 +18,7 @@ It also expects these two env vars to be provided, in order to properly generate
|
||||
So based on this, to run shlink on a local docker service, you should run a command like this:
|
||||
|
||||
```bash
|
||||
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink:stable
|
||||
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
### Interact with shlink's CLI on a running container.
|
||||
@@ -168,10 +168,12 @@ This is the complete list of supported env vars:
|
||||
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
||||
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
|
||||
* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it.
|
||||
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
|
||||
* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
|
||||
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
|
||||
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
|
||||
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
|
||||
|
||||
An example using all env vars could look like this:
|
||||
|
||||
@@ -199,9 +201,11 @@ docker run \
|
||||
-e TASK_WORKER_NUM=32 \
|
||||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||
-e DEFAULT_SHORT_CODES_LENGTH=6 \
|
||||
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||
-e "MERCURE_PUBLIC_HUB_URL=https://example.com" \
|
||||
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
|
||||
-e MERCURE_JWT_SECRET=super_secret_key \
|
||||
-e ANONYMIZE_REMOTE_ADDR=false \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
@@ -243,9 +247,11 @@ The whole configuration should have this format, but it can be split into multip
|
||||
"host": "something.rds.amazonaws.com",
|
||||
"port": "3306"
|
||||
},
|
||||
"geolite_license_key": "kjh23ljkbndskj345",
|
||||
"mercure_public_hub_url": "https://example.com",
|
||||
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
|
||||
"mercure_jwt_secret": "super_secret_key"
|
||||
"mercure_jwt_secret": "super_secret_key",
|
||||
"anonymize_remote_addr": false
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink:stable
|
||||
|
||||
# Push stable tag only if this is not an alpha or beta tag
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && docker push shlinkio/shlink:stable
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
docker build -t shlinkio/shlink:latest .
|
||||
|
||||
@@ -117,6 +117,7 @@ return [
|
||||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
],
|
||||
@@ -160,6 +161,10 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'geolite2' => [
|
||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
];
|
||||
|
||||
17
docs/swagger/definitions/TagInfo.json
Normal file
17
docs/swagger/definitions/TagInfo.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "The unique tag name"
|
||||
},
|
||||
"shortUrlsCount": {
|
||||
"type": "number",
|
||||
"description": "The amount of short URLs using this tag"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "number",
|
||||
"description": "The combined amount of visits received by short URLs with this tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
docs/swagger/definitions/VisitStats.json
Normal file
10
docs/swagger/definitions/VisitStats.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount"],
|
||||
"properties": {
|
||||
"visitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,19 @@
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "withStats",
|
||||
"description": "Whether you want to include also a list with general stats by tag or not.",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -26,12 +39,20 @@
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "object",
|
||||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/TagInfo.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
154
docs/swagger/paths/v2_tags_{tag}_visits.json
Normal file
154
docs/swagger/paths/v2_tags_{tag}_visits.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "getTagVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List visits for tag",
|
||||
"description": "Get the list of visits on any short URL which is tagged with provided tag.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"in": "path",
|
||||
"description": "The tag from which we want to get the visits.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) from which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) until which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "The page to display. Defaults to 1",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "itemsPerPage",
|
||||
"in": "query",
|
||||
"description": "The amount of items to return on every page. Defaults to all the items",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of visits.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/Visit.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"data": [
|
||||
{
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "The tag does not exist.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
docs/swagger/paths/v2_visits.json
Normal file
54
docs/swagger/paths/v2_visits.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "getGlobalVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "Get general visits stats",
|
||||
"description": "Get general visits stats not linked to one specific short URL.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Visits stats.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"$ref": "../definitions/VisitStats.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +78,15 @@
|
||||
"$ref": "paths/v1_tags.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/visits": {
|
||||
"$ref": "paths/v2_visits.json"
|
||||
},
|
||||
"/rest/v{version}/short-urls/{shortCode}/visits": {
|
||||
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
||||
},
|
||||
"/rest/v{version}/tags/{tag}/visits": {
|
||||
"$ref": "paths/v2_tags_{tag}_visits.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
|
||||
@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
@@ -78,10 +79,10 @@ return [
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
|
||||
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\ListTagsCommand::class => [TagService::class],
|
||||
Command\Tag\CreateTagCommand::class => [TagService::class],
|
||||
Command\Tag\RenameTagCommand::class => [TagService::class],
|
||||
Command\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -35,17 +35,20 @@ class ListTagsCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
|
||||
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function getTagsRows(): array
|
||||
{
|
||||
$tags = $this->tagService->listTags();
|
||||
$tags = $this->tagService->tagsInfo();
|
||||
if (empty($tags)) {
|
||||
return [['No tags yet']];
|
||||
return [['No tags found', '-', '-']];
|
||||
}
|
||||
|
||||
return map($tags, fn (Tag $tag) => [(string) $tag]);
|
||||
return map(
|
||||
$tags,
|
||||
fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
@@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -31,28 +32,32 @@ class ListTagsCommandTest extends TestCase
|
||||
/** @test */
|
||||
public function noTagsPrintsEmptyMessage(): void
|
||||
{
|
||||
$listTags = $this->tagService->listTags()->willReturn([]);
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('No tags yet', $output);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
$this->assertStringContainsString('No tags found', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function listOfTagsIsPrinted(): void
|
||||
{
|
||||
$listTags = $this->tagService->listTags()->willReturn([
|
||||
new Tag('foo'),
|
||||
new Tag('bar'),
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([
|
||||
new TagInfo(new Tag('foo'), 10, 2),
|
||||
new TagInfo(new Tag('bar'), 7, 32),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('foo', $output);
|
||||
$this->assertStringContainsString('bar', $output);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
$this->assertStringContainsString('| foo', $output);
|
||||
$this->assertStringContainsString('| bar', $output);
|
||||
$this->assertStringContainsString('| 10 ', $output);
|
||||
$this->assertStringContainsString('| 2 ', $output);
|
||||
$this->assertStringContainsString('| 7 ', $output);
|
||||
$this->assertStringContainsString('| 32 ', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ return [
|
||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
@@ -53,10 +54,15 @@ return [
|
||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\VisitsTracker::class => [
|
||||
'em',
|
||||
EventDispatcherInterface::class,
|
||||
'config.url_shortener.anonymize_remote_addr',
|
||||
],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||
Visit\VisitLocator::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
Options\DeleteShortUrlsOptions::class,
|
||||
|
||||
@@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->setOrderBy(['name' => 'ASC'])
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||
|
||||
@@ -24,4 +24,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder->createField('name', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
|
||||
$builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags');
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->columnName('`date`')
|
||||
->build();
|
||||
|
||||
$builder->addIndex(['date'], 'IDX_visits_date');
|
||||
|
||||
$builder->createField('remoteAddr', Types::STRING)
|
||||
->columnName('remote_addr')
|
||||
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
||||
|
||||
@@ -33,9 +33,11 @@ class SimplifiedConfigParser
|
||||
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
|
||||
'geolite_license_key' => ['geolite2', 'license_key'],
|
||||
'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
|
||||
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
|
||||
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
|
||||
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
||||
@@ -4,16 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Doctrine\Common\Collections;
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
|
||||
class Tag extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
private string $name;
|
||||
private Collections\Collection $shortUrls;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->shortUrls = new Collections\ArrayCollection();
|
||||
}
|
||||
|
||||
public function rename(string $name): void
|
||||
|
||||
@@ -21,24 +21,24 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||
private ShortUrl $shortUrl;
|
||||
private ?VisitLocation $visitLocation = null;
|
||||
|
||||
public function __construct(ShortUrl $shortUrl, Visitor $visitor, ?Chronos $date = null)
|
||||
public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null)
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
$this->date = $date ?? Chronos::now();
|
||||
$this->userAgent = $visitor->getUserAgent();
|
||||
$this->referer = $visitor->getReferer();
|
||||
$this->remoteAddr = $this->obfuscateAddress($visitor->getRemoteAddress());
|
||||
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
|
||||
}
|
||||
|
||||
private function obfuscateAddress(?string $address): ?string
|
||||
private function processAddress(bool $anonymize, ?string $address): ?string
|
||||
{
|
||||
// Localhost addresses do not need to be obfuscated
|
||||
if ($address === null || $address === IpAddress::LOCALHOST) {
|
||||
// Localhost addresses do not need to be anonymized
|
||||
if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($address)->getObfuscatedCopy();
|
||||
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
|
||||
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
private ?int $count = null;
|
||||
|
||||
final public function count(): int
|
||||
{
|
||||
// Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally
|
||||
// cache the count value.
|
||||
// The reason it is cached is because the Paginator is actually calling the method twice.
|
||||
// An inconsistent value could be returned if between the first call and the second one, a new visit is created.
|
||||
// However, it's almost instant, and then the adapter instance is discarded immediately after.
|
||||
|
||||
if ($this->count !== null) {
|
||||
return $this->count;
|
||||
}
|
||||
|
||||
return $this->count = $this->doCount();
|
||||
}
|
||||
|
||||
abstract protected function doCount(): int;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private string $tag;
|
||||
private VisitsParams $params;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params)
|
||||
{
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->params = $params;
|
||||
$this->tag = $tag;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
{
|
||||
return $this->visitRepository->findVisitsByTag(
|
||||
$this->tag,
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
);
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange());
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsPaginatorAdapter implements AdapterInterface
|
||||
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private ShortUrlIdentifier $identifier;
|
||||
@@ -36,7 +35,7 @@ class VisitsPaginatorAdapter implements AdapterInterface
|
||||
);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByShortCode(
|
||||
$this->identifier->shortCode(),
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class TagRepository extends EntityRepository implements TagRepositoryInterface
|
||||
{
|
||||
@@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
|
||||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array
|
||||
{
|
||||
$dql = <<<DQL
|
||||
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
|
||||
FROM Shlinkio\Shlink\Core\Entity\Tag t
|
||||
LEFT JOIN t.shortUrls s
|
||||
LEFT JOIN s.visits v
|
||||
GROUP BY t
|
||||
ORDER BY t.name ASC
|
||||
DQL;
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
|
||||
return map(
|
||||
$query->getResult(),
|
||||
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
interface TagRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
public function deleteByName(array $names): int;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
|
||||
use function array_column;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
{
|
||||
@@ -21,7 +28,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
->from(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.visitLocation'));
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +44,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
|
||||
->setParameter('isEmpty', true);
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
@@ -46,10 +53,10 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v');
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
{
|
||||
$originalQueryBuilder = $qb->setMaxResults($blockSize)
|
||||
->orderBy('v.id', 'ASC');
|
||||
@@ -82,23 +89,13 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('v')
|
||||
->orderBy('v.date', 'DESC');
|
||||
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('COUNT(DISTINCT v.id)');
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
@@ -108,31 +105,103 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
?string $domain,
|
||||
?DateRange $dateRange
|
||||
): QueryBuilder {
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
|
||||
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 'su')
|
||||
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $shortCode);
|
||||
|
||||
// Apply domain filtering
|
||||
if ($domain !== null) {
|
||||
$qb->join('su.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $domain);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('su.domain'));
|
||||
}
|
||||
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
|
||||
|
||||
// Apply date range filtering
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
|
||||
->setParameter('startDate', $dateRange->getStartDate());
|
||||
}
|
||||
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('v.date', ':endDate'))
|
||||
->setParameter('endDate', $dateRange->getEndDate());
|
||||
}
|
||||
$this->applyDatesInline($qb, $dateRange);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findVisitsByTag(
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('s.id')
|
||||
->from(ShortUrl::class, 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', ':tag'))
|
||||
->setParameter('tag', $tag);
|
||||
|
||||
$shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id');
|
||||
$shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb2 = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb2->from(Visit::class, 'v')
|
||||
->where($qb2->expr()->in('v.shortUrl', $shortUrlIds));
|
||||
|
||||
// Apply date range filtering
|
||||
$this->applyDatesInline($qb2, $dateRange);
|
||||
|
||||
return $qb2;
|
||||
}
|
||||
|
||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||
{
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
|
||||
}
|
||||
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
|
||||
{
|
||||
$qb->select('v.id')
|
||||
->orderBy('v.id', 'DESC')
|
||||
// Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
|
||||
// order on sub-queries without offset
|
||||
->setMaxResults($limit ?? PHP_INT_MAX)
|
||||
->setFirstResult($offset ?? 0);
|
||||
$subQuery = $qb->getQuery()->getSQL();
|
||||
|
||||
// A native query builder needs to be used here because DQL and ORM query builders do not accept
|
||||
// sub-queries at "from" and "join" level.
|
||||
// If no sub-query is used, then performance drops dramatically while the "offset" grows.
|
||||
$nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
|
||||
$nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
|
||||
->from('visits', 'v')
|
||||
->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
|
||||
->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
|
||||
->orderBy('v.id', 'DESC');
|
||||
|
||||
$rsm = new ResultSetMappingBuilder($this->getEntityManager());
|
||||
$rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
|
||||
$rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
|
||||
'id' => 'visit_location_id',
|
||||
]);
|
||||
|
||||
$query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
|
||||
|
||||
return $query->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,16 @@ interface VisitRepositoryInterface extends ObjectRepository
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null
|
||||
): int;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findVisitsByTag(
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int;
|
||||
}
|
||||
|
||||
@@ -8,33 +8,39 @@ use Doctrine\ORM;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
{
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private EventDispatcherInterface $eventDispatcher;
|
||||
private bool $anonymizeRemoteAddr;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher)
|
||||
{
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
bool $anonymizeRemoteAddr
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void
|
||||
{
|
||||
$visit = new Visit($shortUrl, $visitor);
|
||||
$visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr);
|
||||
|
||||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
@@ -43,8 +49,6 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
@@ -56,7 +60,7 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepository $repo */
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
@@ -64,4 +68,26 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
$count = $tagRepo->count(['name' => $tag]);
|
||||
if ($count === 0) {
|
||||
throw TagNotFoundException::fromTag($tag);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,24 @@ use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
|
||||
|
||||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator;
|
||||
}
|
||||
|
||||
46
module/Core/src/Tag/Model/TagInfo.php
Normal file
46
module/Core/src/Tag/Model/TagInfo.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
|
||||
final class TagInfo implements JsonSerializable
|
||||
{
|
||||
private Tag $tag;
|
||||
private int $shortUrlsCount;
|
||||
private int $visitsCount;
|
||||
|
||||
public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount)
|
||||
{
|
||||
$this->tag = $tag;
|
||||
$this->shortUrlsCount = $shortUrlsCount;
|
||||
$this->visitsCount = $visitsCount;
|
||||
}
|
||||
|
||||
public function tag(): Tag
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function shortUrlsCount(): int
|
||||
{
|
||||
return $this->shortUrlsCount;
|
||||
}
|
||||
|
||||
public function visitsCount(): int
|
||||
{
|
||||
return $this->visitsCount;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'tag' => $this->tag,
|
||||
'shortUrlsCount' => $this->shortUrlsCount,
|
||||
'visitsCount' => $this->visitsCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\Tag;
|
||||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM;
|
||||
@@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
@@ -25,7 +27,6 @@ class TagService implements TagServiceInterface
|
||||
|
||||
/**
|
||||
* @return Tag[]
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function listTags(): array
|
||||
{
|
||||
@@ -34,6 +35,16 @@ class TagService implements TagServiceInterface
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array
|
||||
{
|
||||
/** @var TagRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
return $repo->findTagsWithInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\Tag;
|
||||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
interface TagServiceInterface
|
||||
{
|
||||
@@ -16,6 +17,11 @@ interface TagServiceInterface
|
||||
*/
|
||||
public function listTags(): array;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array;
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
24
module/Core/src/Visit/Model/VisitsStats.php
Normal file
24
module/Core/src/Visit/Model/VisitsStats.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class VisitsStats implements JsonSerializable
|
||||
{
|
||||
private int $visitsCount;
|
||||
|
||||
public function __construct(int $visitsCount)
|
||||
{
|
||||
$this->visitsCount = $visitsCount;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'visitsCount' => $this->visitsCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
32
module/Core/src/Visit/VisitsStatsHelper.php
Normal file
32
module/Core/src/Visit/VisitsStatsHelper.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
|
||||
class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function getVisitsStats(): VisitsStats
|
||||
{
|
||||
return new VisitsStats($this->getVisitsCount());
|
||||
}
|
||||
|
||||
private function getVisitsCount(): int
|
||||
{
|
||||
/** @var VisitRepository $visitsRepo */
|
||||
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||
return $visitsRepo->count([]);
|
||||
}
|
||||
}
|
||||
12
module/Core/src/Visit/VisitsStatsHelperInterface.php
Normal file
12
module/Core/src/Visit/VisitsStatsHelperInterface.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
|
||||
interface VisitsStatsHelperInterface
|
||||
{
|
||||
public function getVisitsStats(): VisitsStats;
|
||||
}
|
||||
@@ -4,13 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
use function array_chunk;
|
||||
|
||||
class TagRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
protected const ENTITIES_TO_EMPTY = [
|
||||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Tag::class,
|
||||
];
|
||||
|
||||
@@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->assertEquals(2, $this->repo->deleteByName($toDelete));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properTagsInfoIsReturned(): void
|
||||
{
|
||||
$names = ['foo', 'bar', 'baz', 'another'];
|
||||
$tags = [];
|
||||
foreach ($names as $name) {
|
||||
$tag = new Tag($name);
|
||||
$tags[] = $tag;
|
||||
$this->getEntityManager()->persist($tag);
|
||||
}
|
||||
|
||||
[$firstUrlTags] = array_chunk($tags, 3);
|
||||
$secondUrlTags = [$tags[0]];
|
||||
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shortUrl->setTags(new ArrayCollection($firstUrlTags));
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
|
||||
$shortUrl2 = new ShortUrl('');
|
||||
$shortUrl2->setTags(new ArrayCollection($secondUrlTags));
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance()));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findTagsWithInfo();
|
||||
|
||||
$this->assertCount(4, $result);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0],
|
||||
$result[0]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3],
|
||||
$result[1]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3],
|
||||
$result[2]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4],
|
||||
$result[3]->jsonSerialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
@@ -27,6 +29,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Domain::class,
|
||||
Tag::class,
|
||||
];
|
||||
|
||||
private VisitRepository $repo;
|
||||
@@ -125,33 +128,99 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
)));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(): array
|
||||
/** @test */
|
||||
public function findVisitsByTagReturnsProperData(): void
|
||||
{
|
||||
$foo = new Tag('foo');
|
||||
$this->getEntityManager()->persist($foo);
|
||||
|
||||
/** @var ShortUrl $shortUrl */
|
||||
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl2 */
|
||||
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl3 */
|
||||
[,, $shortUrl3] = $this->createShortUrlsAndVisits(false);
|
||||
|
||||
$shortUrl->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl2->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl3->setTags(new ArrayCollection([$foo]));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertCount(0, $this->repo->findVisitsByTag('invalid'));
|
||||
$this->assertCount(18, $this->repo->findVisitsByTag((string) $foo));
|
||||
$this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countVisitsByTagReturnsProperData(): void
|
||||
{
|
||||
$foo = new Tag('foo');
|
||||
$this->getEntityManager()->persist($foo);
|
||||
|
||||
/** @var ShortUrl $shortUrl */
|
||||
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl2 */
|
||||
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
|
||||
|
||||
$shortUrl->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl2->setTags(new ArrayCollection([$foo]));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertEquals(0, $this->repo->countVisitsByTag('invalid'));
|
||||
$this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
|
||||
$this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(bool $withDomain = true): array
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$domain = 'example.com';
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'customSlug' => $shortCode,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrlWithDomain,
|
||||
$shortUrl,
|
||||
Visitor::emptyInstance(),
|
||||
true,
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
return [$shortCode, $domain];
|
||||
if ($withDomain) {
|
||||
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'customSlug' => $shortCode,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrlWithDomain,
|
||||
Visitor::emptyInstance(),
|
||||
true,
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
return [$shortCode, $domain, $shortUrl];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,11 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'geolite_license_key' => 'kjh23ljkbndskj345',
|
||||
'mercure_public_hub_url' => 'public_url',
|
||||
'mercure_internal_hub_url' => 'internal_url',
|
||||
'mercure_jwt_secret' => 'super_secret_value',
|
||||
'anonymize_remote_addr' => false,
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
@@ -91,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'anonymize_remote_addr' => false,
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
@@ -131,6 +134,10 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
],
|
||||
],
|
||||
|
||||
'geolite2' => [
|
||||
'license_key' => 'kjh23ljkbndskj345',
|
||||
],
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'public_url',
|
||||
'internal_hub_url' => 'internal_url',
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Entity;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
@@ -18,7 +19,7 @@ class VisitTest extends TestCase
|
||||
*/
|
||||
public function isProperlyJsonSerialized(?Chronos $date): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), $date);
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date);
|
||||
|
||||
$this->assertEquals([
|
||||
'referer' => 'some site',
|
||||
@@ -33,4 +34,25 @@ class VisitTest extends TestCase
|
||||
yield 'null date' => [null];
|
||||
yield 'not null date' => [Chronos::now()->subDays(10)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAddresses
|
||||
*/
|
||||
public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize);
|
||||
|
||||
$this->assertEquals($expectedAddress, $visit->getRemoteAddr());
|
||||
}
|
||||
|
||||
public function provideAddresses(): iterable
|
||||
{
|
||||
yield 'anonymized null address' => [true, null, null];
|
||||
yield 'non-anonymized null address' => [false, null, null];
|
||||
yield 'anonymized localhost' => [true, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
|
||||
yield 'non-anonymized localhost' => [false, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
|
||||
yield 'anonymized regular address' => [true, '1.2.3.4', '1.2.3.0'];
|
||||
yield 'non-anonymized regular address' => [false, '1.2.3.4', '1.2.3.4'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
private VisitsForTagPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([]));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledEveryTimeItemsAreFetched(): void
|
||||
{
|
||||
$count = 3;
|
||||
$limit = 1;
|
||||
$offset = 5;
|
||||
$findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->getItems($offset, $limit);
|
||||
}
|
||||
|
||||
$findVisits->shouldHaveBeenCalledTimes($count);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledOnlyOnceForCount(): void
|
||||
{
|
||||
$count = 3;
|
||||
$countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->count();
|
||||
}
|
||||
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
private VisitsPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->adapter = new VisitsPaginatorAdapter(
|
||||
$this->repo->reveal(),
|
||||
new ShortUrlIdentifier(''),
|
||||
VisitsParams::fromRawData([]),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledEveryTimeItemsAreFetched(): void
|
||||
{
|
||||
$count = 3;
|
||||
$limit = 1;
|
||||
$offset = 5;
|
||||
$findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->getItems($offset, $limit);
|
||||
}
|
||||
|
||||
$findVisits->shouldHaveBeenCalledTimes($count);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledOnlyOnceForCount(): void
|
||||
{
|
||||
$count = 3;
|
||||
$countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->count();
|
||||
}
|
||||
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\Core\Service\Tag;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@@ -13,16 +12,21 @@ use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
|
||||
class TagServiceTest extends TestCase
|
||||
{
|
||||
private TagService $service;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->repo = $this->prophesize(TagRepository::class);
|
||||
$this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled();
|
||||
|
||||
$this->service = new TagService($this->em->reveal());
|
||||
}
|
||||
|
||||
@@ -31,36 +35,41 @@ class TagServiceTest extends TestCase
|
||||
{
|
||||
$expected = [new Tag('foo'), new Tag('bar')];
|
||||
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
|
||||
|
||||
$result = $this->service->listTags();
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tagsInfoDelegatesOnRepository(): void
|
||||
{
|
||||
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
|
||||
|
||||
$find = $this->repo->findTagsWithInfo()->willReturn($expected);
|
||||
|
||||
$result = $this->service->tagsInfo();
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function deleteTagsDelegatesOnRepository(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
|
||||
|
||||
$this->service->deleteTags(['foo', 'bar']);
|
||||
|
||||
$delete->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function createTagsPersistsEntities(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
@@ -68,7 +77,6 @@ class TagServiceTest extends TestCase
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
$persist->shouldHaveBeenCalledTimes(2);
|
||||
$flush->shouldHaveBeenCalled();
|
||||
}
|
||||
@@ -76,12 +84,9 @@ class TagServiceTest extends TestCase
|
||||
/** @test */
|
||||
public function renameInvalidTagThrowsException(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
|
||||
|
||||
$find->shouldBeCalled();
|
||||
$getRepo->shouldBeCalled();
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
|
||||
$this->service->renameTag('foo', 'bar');
|
||||
@@ -95,10 +100,8 @@ class TagServiceTest extends TestCase
|
||||
{
|
||||
$expected = new Tag('foo');
|
||||
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected);
|
||||
$countTags = $repo->count(Argument::cetera())->willReturn($count);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected);
|
||||
$countTags = $this->repo->count(Argument::cetera())->willReturn($count);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$tag = $this->service->renameTag($oldName, $newName);
|
||||
@@ -106,7 +109,6 @@ class TagServiceTest extends TestCase
|
||||
$this->assertSame($expected, $tag);
|
||||
$this->assertEquals($newName, (string) $tag);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
$flush->shouldHaveBeenCalled();
|
||||
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
|
||||
}
|
||||
@@ -120,14 +122,11 @@ class TagServiceTest extends TestCase
|
||||
/** @test */
|
||||
public function renameTagToAnExistingNameThrowsException(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$countTags = $repo->count(Argument::cetera())->willReturn(1);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$countTags = $this->repo->count(Argument::cetera())->willReturn(1);
|
||||
$flush = $this->em->flush(Argument::any())->willReturn(null);
|
||||
|
||||
$find->shouldBeCalled();
|
||||
$getRepo->shouldBeCalled();
|
||||
$countTags->shouldBeCalled();
|
||||
$flush->shouldNotBeCalled();
|
||||
$this->expectException(TagConflictException::class);
|
||||
|
||||
@@ -6,20 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
|
||||
@@ -37,7 +39,7 @@ class VisitsTrackerTest extends TestCase
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal());
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -53,25 +55,6 @@ class VisitsTrackerTest extends TestCase
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackedIpAddressGetsObfuscated(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
|
||||
$this->em->persist(Argument::any())->will(function ($args) {
|
||||
/** @var Visit $visit */
|
||||
$visit = $args[0];
|
||||
Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr());
|
||||
$visit->setId('1');
|
||||
return $visit;
|
||||
})->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1'));
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function infoReturnsVisitsForCertainShortCode(): void
|
||||
{
|
||||
@@ -105,4 +88,40 @@ class VisitsTrackerTest extends TestCase
|
||||
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$count = $repo->count(['name' => $tag])->willReturn(0);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
$count->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->visitsForTag($tag, new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function visitsForTagAreReturnedAsExpected(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$count = $repo->count(['name' => $tag])->willReturn(1);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list);
|
||||
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams());
|
||||
|
||||
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
50
module/Core/test/Visit/VisitsStatsHelperTest.php
Normal file
50
module/Core/test/Visit/VisitsStatsHelperTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class VisitsStatsHelperTest extends TestCase
|
||||
{
|
||||
private VisitsStatsHelper $helper;
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->helper = new VisitsStatsHelper($this->em->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideCounts
|
||||
*/
|
||||
public function returnsExpectedVisitsStats(int $expectedCount): void
|
||||
{
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$count = $repo->count([])->willReturn($expectedCount);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$stats = $this->helper->getVisitsStats();
|
||||
|
||||
$this->assertEquals(new VisitsStats($expectedCount), $stats);
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideCounts(): iterable
|
||||
{
|
||||
return map(range(0, 50, 5), fn (int $value) => [$value]);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ namespace Shlinkio\Shlink\Rest;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
|
||||
return [
|
||||
@@ -28,7 +29,9 @@ return [
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
|
||||
@@ -46,36 +49,29 @@ return [
|
||||
ConfigAbstractFactory::class => [
|
||||
ApiKeyService::class => ['em'],
|
||||
|
||||
Action\HealthAction::class => ['em', AppOptions::class, 'Logger_Shlink'],
|
||||
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'],
|
||||
Action\ShortUrl\CreateShortUrlAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\HealthAction::class => ['em', AppOptions::class],
|
||||
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'],
|
||||
Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
ApiKeyService::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class],
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [
|
||||
Service\ShortUrlService::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
|
||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||
Action\Tag\DeleteTagsAction::class => [TagService::class],
|
||||
Action\Tag\CreateTagsAction::class => [TagService::class],
|
||||
Action\Tag\UpdateTagAction::class => [TagService::class],
|
||||
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
|
||||
|
||||
@@ -26,7 +26,9 @@ return [
|
||||
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
||||
// Visits
|
||||
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\Visit\TagVisitsAction::getRouteDef(),
|
||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||
|
||||
// Tags
|
||||
Action\Tag\ListTagsAction::getRouteDef(),
|
||||
|
||||
@@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
use function array_merge;
|
||||
|
||||
@@ -17,13 +15,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet
|
||||
protected const ROUTE_PATH = '';
|
||||
protected const ROUTE_ALLOWED_METHODS = [];
|
||||
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Throwable;
|
||||
|
||||
@@ -24,9 +23,8 @@ class HealthAction extends AbstractRestAction
|
||||
private EntityManagerInterface $em;
|
||||
private AppOptions $options;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, AppOptions $options, ?LoggerInterface $logger = null)
|
||||
public function __construct(EntityManagerInterface $em, AppOptions $options)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->em = $em;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use Cake\Chronos\Chronos;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
|
||||
use Shlinkio\Shlink\Rest\Exception\MercureException;
|
||||
use Throwable;
|
||||
@@ -23,12 +22,8 @@ class MercureInfoAction extends AbstractRestAction
|
||||
private JwtProviderInterface $jwtProvider;
|
||||
private array $mercureConfig;
|
||||
|
||||
public function __construct(
|
||||
JwtProviderInterface $jwtProvider,
|
||||
array $mercureConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(JwtProviderInterface $jwtProvider, array $mercureConfig)
|
||||
{
|
||||
$this->jwtProvider = $jwtProvider;
|
||||
$this->mercureConfig = $mercureConfig;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
@@ -19,12 +18,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
|
||||
{
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
@@ -19,9 +18,8 @@ class DeleteShortUrlAction extends AbstractRestAction
|
||||
|
||||
private DeleteShortUrlServiceInterface $deleteShortUrlService;
|
||||
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->deleteShortUrlService = $deleteShortUrlService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
@@ -20,9 +19,8 @@ class EditShortUrlAction extends AbstractRestAction
|
||||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
@@ -20,9 +19,8 @@ class EditShortUrlTagsAction extends AbstractRestAction
|
||||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
@@ -24,12 +23,8 @@ class ListShortUrlsAction extends AbstractRestAction
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlServiceInterface $shortUrlService,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
|
||||
{
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
@@ -21,12 +20,8 @@ class ResolveShortUrlAction extends AbstractRestAction
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig)
|
||||
{
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
@@ -22,10 +21,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ApiKeyServiceInterface $apiKeyService,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
array $domainConfig
|
||||
) {
|
||||
parent::__construct($urlShortener, $domainConfig, $logger);
|
||||
parent::__construct($urlShortener, $domainConfig);
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class CreateTagsAction extends AbstractRestAction
|
||||
@@ -18,9 +17,8 @@ class CreateTagsAction extends AbstractRestAction
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class DeleteTagsAction extends AbstractRestAction
|
||||
@@ -18,9 +17,8 @@ class DeleteTagsAction extends AbstractRestAction
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class ListTagsAction extends AbstractRestAction
|
||||
{
|
||||
protected const ROUTE_PATH = '/tags';
|
||||
@@ -18,24 +20,31 @@ class ListTagsAction extends AbstractRestAction
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$query = $request->getQueryParams();
|
||||
$withStats = ($query['withStats'] ?? null) === 'true';
|
||||
|
||||
if (! $withStats) {
|
||||
return new JsonResponse([
|
||||
'tags' => [
|
||||
'data' => $this->tagService->listTags(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$tagsInfo = $this->tagService->tagsInfo();
|
||||
$data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag());
|
||||
|
||||
return new JsonResponse([
|
||||
'tags' => [
|
||||
'data' => $this->tagService->listTags(),
|
||||
'data' => $data,
|
||||
'stats' => $tagsInfo,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class UpdateTagAction extends AbstractRestAction
|
||||
@@ -19,9 +18,8 @@ class UpdateTagAction extends AbstractRestAction
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
|
||||
31
module/Rest/src/Action/Visit/GlobalVisitsAction.php
Normal file
31
module/Rest/src/Action/Visit/GlobalVisitsAction.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class GlobalVisitsAction extends AbstractRestAction
|
||||
{
|
||||
protected const ROUTE_PATH = '/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private VisitsStatsHelperInterface $statsHelper;
|
||||
|
||||
public function __construct(VisitsStatsHelperInterface $statsHelper)
|
||||
{
|
||||
$this->statsHelper = $statsHelper;
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return new JsonResponse([
|
||||
'visits' => $this->statsHelper->getVisitsStats(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,13 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class GetVisitsAction extends AbstractRestAction
|
||||
class ShortUrlVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
@@ -23,9 +22,8 @@ class GetVisitsAction extends AbstractRestAction
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker, ?LoggerInterface $logger = null)
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
}
|
||||
|
||||
38
module/Rest/src/Action/Visit/TagVisitsAction.php
Normal file
38
module/Rest/src/Action/Visit/TagVisitsAction.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class TagVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/tags/{tag}/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$tag = $request->getAttribute('tag', '');
|
||||
$visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams()));
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
module/Rest/test-api/Action/GlobalVisitsActionTest.php
Normal file
21
module/Rest/test-api/Action/GlobalVisitsActionTest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
class GlobalVisitsActionTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function returnsExpectedVisitsStats(): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/visits');
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertArrayHasKey('visits', $payload);
|
||||
$this->assertArrayHasKey('visitsCount', $payload['visits']);
|
||||
$this->assertEquals(7, $payload['visits']['visitsCount']);
|
||||
}
|
||||
}
|
||||
50
module/Rest/test-api/Action/ListTagsActionTest.php
Normal file
50
module/Rest/test-api/Action/ListTagsActionTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
class ListTagsActionTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideQueries
|
||||
*/
|
||||
public function expectedListOfTagsIsReturned(array $query, array $expectedTags): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(['tags' => $expectedTags], $payload);
|
||||
}
|
||||
|
||||
public function provideQueries(): iterable
|
||||
{
|
||||
yield 'stats not requested' => [[], [
|
||||
'data' => ['bar', 'baz', 'foo'],
|
||||
]];
|
||||
yield 'stats requested' => [['withStats' => 'true'], [
|
||||
'data' => ['bar', 'baz', 'foo'],
|
||||
'stats' => [
|
||||
[
|
||||
'tag' => 'bar',
|
||||
'shortUrlsCount' => 1,
|
||||
'visitsCount' => 2,
|
||||
],
|
||||
[
|
||||
'tag' => 'baz',
|
||||
'shortUrlsCount' => 0,
|
||||
'visitsCount' => 0,
|
||||
],
|
||||
[
|
||||
'tag' => 'foo',
|
||||
'shortUrlsCount' => 2,
|
||||
'visitsCount' => 5,
|
||||
],
|
||||
],
|
||||
]];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function sprintf;
|
||||
|
||||
class GetVisitsActionTest extends ApiTestCase
|
||||
class ShortUrlVisitsActionTest extends ApiTestCase
|
||||
{
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
46
module/Rest/test-api/Action/TagVisitsActionTest.php
Normal file
46
module/Rest/test-api/Action/TagVisitsActionTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class TagVisitsActionTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTags
|
||||
*/
|
||||
public function expectedVisitsAreReturned(string $tag, int $expectedVisitsAmount): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertArrayHasKey('visits', $payload);
|
||||
$this->assertArrayHasKey('data', $payload['visits']);
|
||||
$this->assertCount($expectedVisitsAmount, $payload['visits']['data']);
|
||||
}
|
||||
|
||||
public function provideTags(): iterable
|
||||
{
|
||||
yield 'foo' => ['foo', 5];
|
||||
yield 'bar' => ['bar', 2];
|
||||
yield 'baz' => ['baz', 0];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notFoundErrorIsReturnedForInvalidTags(): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits');
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
|
||||
$this->assertEquals('TAG_NOT_FOUND', $payload['type']);
|
||||
$this->assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']);
|
||||
$this->assertEquals('Tag not found', $payload['title']);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface
|
||||
$manager->persist($fooTag);
|
||||
$barTag = new Tag('bar');
|
||||
$manager->persist($barTag);
|
||||
$manager->persist(new Tag('baz'));
|
||||
|
||||
/** @var ShortUrl $abcShortUrl */
|
||||
$abcShortUrl = $this->getReference('abc123_short_url');
|
||||
|
||||
@@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction;
|
||||
|
||||
class CreateTagsActionTest extends TestCase
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction;
|
||||
|
||||
class DeleteTagsActionTest extends TestCase
|
||||
|
||||
@@ -4,15 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
|
||||
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
|
||||
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
|
||||
class ListTagsActionTest extends TestCase
|
||||
{
|
||||
private ListTagsAction $action;
|
||||
@@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase
|
||||
$this->action = new ListTagsAction($this->tagService->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function returnsDataFromService(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideNoStatsQueries
|
||||
*/
|
||||
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
|
||||
{
|
||||
$listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]);
|
||||
$tags = [new Tag('foo'), new Tag('bar')];
|
||||
$listTags = $this->tagService->listTags()->willReturn($tags);
|
||||
|
||||
$resp = $this->action->handle(new ServerRequest());
|
||||
/** @var JsonResponse $resp */
|
||||
$resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query));
|
||||
$payload = $resp->getPayload();
|
||||
|
||||
$this->assertEquals([
|
||||
'tags' => [
|
||||
'data' => $tags,
|
||||
],
|
||||
], $payload);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideNoStatsQueries(): iterable
|
||||
{
|
||||
yield 'no query' => [[]];
|
||||
yield 'withStats is false' => [['withStats' => 'withStats']];
|
||||
yield 'withStats is something else' => [['withStats' => 'foo']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function returnsStatsWhenRequested(): void
|
||||
{
|
||||
$stats = [
|
||||
new TagInfo(new Tag('foo'), 1, 1),
|
||||
new TagInfo(new Tag('bar'), 3, 10),
|
||||
];
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn($stats);
|
||||
|
||||
/** @var JsonResponse $resp */
|
||||
$resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true']));
|
||||
$payload = $resp->getPayload();
|
||||
|
||||
$this->assertEquals([
|
||||
'tags' => [
|
||||
'data' => ['foo', 'bar'],
|
||||
'stats' => $stats,
|
||||
],
|
||||
], json_decode((string) $resp->getBody()));
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
], $payload);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction;
|
||||
|
||||
class UpdateTagActionTest extends TestCase
|
||||
|
||||
39
module/Rest/test/Action/Visit/GlobalVisitsActionTest.php
Normal file
39
module/Rest/test/Action/Visit/GlobalVisitsActionTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction;
|
||||
|
||||
class GlobalVisitsActionTest extends TestCase
|
||||
{
|
||||
private GlobalVisitsAction $action;
|
||||
private ObjectProphecy $helper;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->helper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->action = new GlobalVisitsAction($this->helper->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function statsAreReturnedFromHelper(): void
|
||||
{
|
||||
$stats = new VisitsStats(5);
|
||||
$getStats = $this->helper->getVisitsStats()->willReturn($stats);
|
||||
|
||||
/** @var JsonResponse $resp */
|
||||
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
|
||||
$payload = $resp->getPayload();
|
||||
|
||||
$this->assertEquals($payload, ['visits' => $stats]);
|
||||
$getStats->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -15,17 +15,17 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction;
|
||||
|
||||
class GetVisitsActionTest extends TestCase
|
||||
class ShortUrlVisitsActionTest extends TestCase
|
||||
{
|
||||
private GetVisitsAction $action;
|
||||
private ShortUrlVisitsAction $action;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
|
||||
$this->action = new GetVisitsAction($this->visitsTracker->reveal());
|
||||
$this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
41
module/Rest/test/Action/Visit/TagVisitsActionTest.php
Normal file
41
module/Rest/test/Action/Visit/TagVisitsActionTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Paginator\Adapter\ArrayAdapter;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction;
|
||||
|
||||
class TagVisitsActionTest extends TestCase
|
||||
{
|
||||
private TagVisitsAction $action;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
|
||||
$this->action = new TagVisitsAction($this->visitsTracker->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingCorrectShortCodeReturnsVisits(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class))->willReturn(
|
||||
new Paginator(new ArrayAdapter([])),
|
||||
);
|
||||
|
||||
$response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag));
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$getVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ parameters:
|
||||
ignoreErrors:
|
||||
- '#AbstractQuery::setParameters()#'
|
||||
- '#mustRun()#'
|
||||
- '#AssociationBuilder::setOrderBy#'
|
||||
|
||||
Reference in New Issue
Block a user