mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge pull request #1017 from acelaya-forks/feature/not-found-tracking
Feature/not found tracking
This commit is contained in:
commit
db6c83eefd
91 changed files with 2067 additions and 509 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -9,5 +9,6 @@ data/shlink-tests.db
|
|||
data/GeoLite2-City.mmdb
|
||||
data/GeoLite2-City.mmdb.*
|
||||
docs/swagger-ui*
|
||||
docs/mercure.html
|
||||
docker-compose.override.yml
|
||||
.phpunit.result.cache
|
||||
|
|
|
@ -16,6 +16,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns.
|
||||
|
||||
* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code.
|
||||
* [#675](https://github.com/shlinkio/shlink/issues/1000) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits.
|
||||
|
||||
This behavior is enabled by default, but you can opt out via env vars or config options.
|
||||
|
||||
This new orphan visits can be consumed in these ways:
|
||||
|
||||
* The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs.
|
||||
* The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits.
|
||||
|
||||
### Changed
|
||||
* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.
|
||||
|
|
|
@ -47,11 +47,11 @@
|
|||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.7",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "dev-main#b889f5d as 3.5",
|
||||
"shlinkio/shlink-common": "dev-main#62d4b84 as 3.5",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.0",
|
||||
"shlinkio/shlink-importer": "^2.2",
|
||||
"shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4",
|
||||
"shlinkio/shlink-installer": "dev-develop#c489d3f as 5.4",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
|
|
|
@ -41,6 +41,7 @@ return [
|
|||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\UrlShortener\OrphanVisitsTrackingConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\Helper;
|
|||
use Mezzio\ProblemDetails;
|
||||
use Mezzio\Router;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
use RKA\Middleware\IpAddress;
|
||||
|
||||
use function extension_loaded;
|
||||
|
||||
|
@ -68,6 +69,10 @@ return [
|
|||
],
|
||||
'not-found' => [
|
||||
'middleware' => [
|
||||
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
||||
IpAddress::class,
|
||||
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
||||
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
||||
],
|
||||
|
|
|
@ -13,13 +13,14 @@ return [
|
|||
'schema' => 'https',
|
||||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => false,
|
||||
'validate_url' => false, // Deprecated
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
'auto_resolve_titles' => false,
|
||||
'track_orphan_visits' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
53
data/migrations/Version20210207100807.php
Normal file
53
data/migrations/Version20210207100807.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
final class Version20210207100807 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$shortUrlId = $visits->getColumn('short_url_id');
|
||||
|
||||
$this->skipIf(! $shortUrlId->getNotnull());
|
||||
|
||||
$shortUrlId->setNotnull(false);
|
||||
|
||||
$visits->addColumn('visited_url', Types::STRING, [
|
||||
'length' => Visitor::VISITED_URL_MAX_LENGTH,
|
||||
'notnull' => false,
|
||||
]);
|
||||
$visits->addColumn('type', Types::STRING, [
|
||||
'length' => 255,
|
||||
'default' => Visit::TYPE_VALID_SHORT_URL,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$shortUrlId = $visits->getColumn('short_url_id');
|
||||
|
||||
$this->skipIf($shortUrlId->getNotnull());
|
||||
|
||||
$shortUrlId->setNotnull(true);
|
||||
$visits->dropColumn('visited_url');
|
||||
$visits->dropColumn('type');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -126,6 +126,7 @@ return [
|
|||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
||||
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# Track visits to 'base_url', 'invalid_short_url' and 'regular_404'
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2021-02-07
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Shlink has the mechanism to return either custom errors or custom redirects when visiting the instance's base URL, an invalid short URL, or any other kind of URL that would result in a "Not found" error.
|
||||
|
||||
However, it does not track visits to any of those, just to valid short URLs.
|
||||
|
||||
The intention is to change that, and allow users to track the cases mentioned above.
|
||||
|
||||
## Considered option
|
||||
|
||||
* Create a new table to track visits o this kind.
|
||||
* Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields.
|
||||
|
||||
## Decision outcome
|
||||
|
||||
The decision is to use the existing table, as making the short URL nullable can be handled seamlessly by using named constructors, and it has a lot of benefits on regards of reusing existing components.
|
||||
|
||||
Also, the domain name this kind of visits will receive is "Orphan Visits", as they are detached from any existing short URL.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### New table
|
||||
|
||||
* Good because we don't touch existing models and tables, reducing the risk to introduce a backwards compatibility break.
|
||||
* Bad because we will have to repeat data modeling and logic, or refactor some components to support both contexts. This in turn increases the options to introduce a BC break.
|
||||
|
||||
### Reuse existing table
|
||||
|
||||
* Good because all the mechanisms in place to handle visits will work out of the box, including locating visits and such.
|
||||
* Bad because we will have more optional properties, which means more double checks in many places.
|
|
@ -2,4 +2,5 @@
|
|||
|
||||
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||
|
||||
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
|
||||
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)
|
||||
|
|
|
@ -58,6 +58,23 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://shlink.io/new-orphan-visit": {
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new orphan visit.",
|
||||
"operationId": "newOrphanVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/OrphanVisit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
@ -179,6 +196,46 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"OrphanVisit": {
|
||||
"allOf": [
|
||||
{"$ref": "#/components/schemas/Visit"},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visitedUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_short_url",
|
||||
"base_url",
|
||||
"regular_404"
|
||||
],
|
||||
"description": "Tells the type of orphan visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"example": {
|
||||
"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"
|
||||
},
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
}
|
||||
},
|
||||
"VisitLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
23
docs/swagger/definitions/OrphanVisit.json
Normal file
23
docs/swagger/definitions/OrphanVisit.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["visitedUrl", "type"],
|
||||
"allOf": [{
|
||||
"$ref": "./Visit.json"
|
||||
}],
|
||||
"properties": {
|
||||
"visitedUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_short_url",
|
||||
"base_url",
|
||||
"regular_404"
|
||||
],
|
||||
"description": "Tells the type of orphan visit"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["referer", "date", "userAgent", "visitLocation"],
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string",
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount"],
|
||||
"required": ["visitsCount", "orphanVisitsCount"],
|
||||
"properties": {
|
||||
"visitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received."
|
||||
"description": "The total amount of visits received on any short URL."
|
||||
},
|
||||
"orphanVisitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,8 @@
|
|||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874
|
||||
"visitsCount": 1569874,
|
||||
"orphanVisitsCount": 71345
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
141
docs/swagger/paths/v2_visits_orphan.json
Normal file
141
docs/swagger/paths/v2_visits_orphan.json
Normal file
|
@ -0,0 +1,141 @@
|
|||
{
|
||||
"get": {
|
||||
"operationId": "getOrphanVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List orphan visits",
|
||||
"description": "Get the list of visits to invalid short URLs, the base URL or any other 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"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/OrphanVisit.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,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"visitedUrl": "https://doma.in/foo",
|
||||
"type": "invalid_short_url"
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"visitedUrl": "https://doma.in/foo/bar/baz",
|
||||
"type": "regular_404"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -95,6 +95,9 @@
|
|||
"/rest/v{version}/tags/{tag}/visits": {
|
||||
"$ref": "paths/v2_tags_{tag}_visits.json"
|
||||
},
|
||||
"/rest/v{version}/visits/orphan": {
|
||||
"$ref": "paths/v2_visits_orphan.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
|
|
|
@ -74,7 +74,7 @@ return [
|
|||
Service\ShortUrlService::class,
|
||||
ShortUrlDataTransformer::class,
|
||||
],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
|
|
|
@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
|||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
@ -27,11 +27,11 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,10 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
|
||||
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||
$paginator = $this->visitsHelper->visitsForShortUrl(
|
||||
$identifier,
|
||||
new VisitsParams(new DateRange($startDate, $endDate)),
|
||||
);
|
||||
|
||||
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
|
|
|
@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
@ -31,12 +31,12 @@ class GetVisitsCommandTest extends TestCase
|
|||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
|
||||
$command = new GetVisitsCommand($this->visitsTracker->reveal());
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$command = new GetVisitsCommand($this->visitsHelper->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
|
@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
|
|||
public function noDateFlagsTriesToListWithoutDateRange(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(
|
||||
$this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(null, null)),
|
||||
)
|
||||
|
@ -62,7 +62,7 @@ class GetVisitsCommandTest extends TestCase
|
|||
$shortCode = 'abc123';
|
||||
$startDate = '2016-01-01';
|
||||
$endDate = '2016-02-01';
|
||||
$this->visitsTracker->info(
|
||||
$this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
||||
)
|
||||
|
@ -81,8 +81,10 @@ class GetVisitsCommandTest extends TestCase
|
|||
{
|
||||
$shortCode = 'abc123';
|
||||
$startDate = 'foo';
|
||||
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
|
||||
->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
$info = $this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange()),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'shortCode' => $shortCode,
|
||||
|
@ -101,9 +103,9 @@ class GetVisitsCommandTest extends TestCase
|
|||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([
|
||||
(new Visit(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '')))->locate(
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
),
|
||||
])),
|
||||
|
|
|
@ -77,7 +77,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
bool $expectWarningPrint,
|
||||
array $args
|
||||
): void {
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4'));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
|
@ -121,7 +121,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
*/
|
||||
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
|
||||
{
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $address));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
|
@ -154,7 +154,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4'));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
|
|
|
@ -15,6 +15,8 @@ return [
|
|||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
|
||||
|
||||
|
@ -24,16 +26,20 @@ return [
|
|||
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
|
||||
|
||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::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,
|
||||
Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
|
||||
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
||||
|
@ -58,10 +64,11 @@ return [
|
|||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
|
||||
ErrorHandler\NotFoundRedirectHandler::class => [
|
||||
NotFoundRedirectOptions::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
'config.router.base_path',
|
||||
],
|
||||
|
||||
Options\AppOptions::class => ['config.app_options'],
|
||||
|
@ -75,10 +82,10 @@ return [
|
|||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class,
|
||||
],
|
||||
Service\VisitsTracker::class => [
|
||||
Visit\VisitsTracker::class => [
|
||||
'em',
|
||||
EventDispatcherInterface::class,
|
||||
'config.url_shortener.anonymize_remote_addr',
|
||||
Options\UrlShortenerOptions::class,
|
||||
],
|
||||
Service\ShortUrlService::class => [
|
||||
'em',
|
||||
|
@ -104,14 +111,14 @@ return [
|
|||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
|
@ -126,7 +133,10 @@ return [
|
|||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],
|
||||
Mercure\MercureUpdatesGenerator::class => [
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||
],
|
||||
|
||||
Importer\ImportedLinksProcessor::class => [
|
||||
'em',
|
||||
|
|
|
@ -47,11 +47,22 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->build();
|
||||
|
||||
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
||||
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
|
||||
$builder->createField('visitedUrl', Types::STRING)
|
||||
->columnName('visited_url')
|
||||
->length(Visitor::VISITED_URL_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('type', Types::STRING)
|
||||
->columnName('type')
|
||||
->length(255)
|
||||
->build();
|
||||
};
|
||||
|
|
|
@ -20,28 +20,28 @@ return [
|
|||
],
|
||||
],
|
||||
'async' => [
|
||||
EventDispatcher\Event\ShortUrlVisited::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class,
|
||||
EventDispatcher\Event\UrlVisited::class => [
|
||||
EventDispatcher\LocateVisit::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\LocateVisit::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\LocateVisit::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
|
|
|
@ -9,6 +9,7 @@ use DateTimeInterface;
|
|||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
use function Functional\reduce_left;
|
||||
use function is_array;
|
||||
|
@ -44,6 +45,26 @@ function parseDateFromQuery(array $query, string $dateName): ?Chronos
|
|||
return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
|
||||
}
|
||||
|
||||
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
|
||||
{
|
||||
$startDate = parseDateFromQuery($query, $startDateName);
|
||||
$endDate = parseDateFromQuery($query, $endDateName);
|
||||
|
||||
if ($startDate === null && $endDate === null) {
|
||||
return DateRange::emptyInstance();
|
||||
}
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
return DateRange::withStartAndEndDate($startDate, $endDate);
|
||||
}
|
||||
|
||||
if ($startDate !== null) {
|
||||
return DateRange::withStartDate($startDate);
|
||||
}
|
||||
|
||||
return DateRange::withEndDate($endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|DateTimeInterface|Chronos|null $date
|
||||
*/
|
||||
|
|
|
@ -20,7 +20,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
|||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
||||
|
||||
use function array_key_exists;
|
||||
use function array_merge;
|
||||
|
|
|
@ -11,8 +11,8 @@ use Psr\Http\Server\RequestHandlerInterface;
|
|||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
||||
{
|
||||
|
|
|
@ -14,20 +14,29 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
|||
|
||||
class Visit extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
public const TYPE_VALID_SHORT_URL = 'valid_short_url';
|
||||
public const TYPE_INVALID_SHORT_URL = 'invalid_short_url';
|
||||
public const TYPE_BASE_URL = 'base_url';
|
||||
public const TYPE_REGULAR_404 = 'regular_404';
|
||||
|
||||
private string $referer;
|
||||
private Chronos $date;
|
||||
private ?string $remoteAddr = null;
|
||||
private ?string $remoteAddr;
|
||||
private ?string $visitedUrl;
|
||||
private string $userAgent;
|
||||
private ShortUrl $shortUrl;
|
||||
private string $type;
|
||||
private ?ShortUrl $shortUrl;
|
||||
private ?VisitLocation $visitLocation = null;
|
||||
|
||||
public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null)
|
||||
private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true)
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
$this->date = $date ?? Chronos::now();
|
||||
$this->date = Chronos::now();
|
||||
$this->userAgent = $visitor->getUserAgent();
|
||||
$this->referer = $visitor->getReferer();
|
||||
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
|
||||
$this->visitedUrl = $visitor->getVisitedUrl();
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
private function processAddress(bool $anonymize, ?string $address): ?string
|
||||
|
@ -44,6 +53,26 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
}
|
||||
}
|
||||
|
||||
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize);
|
||||
}
|
||||
|
||||
public function getRemoteAddr(): ?string
|
||||
{
|
||||
return $this->remoteAddr;
|
||||
|
@ -54,7 +83,7 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
return ! empty($this->remoteAddr);
|
||||
}
|
||||
|
||||
public function getShortUrl(): ShortUrl
|
||||
public function getShortUrl(): ?ShortUrl
|
||||
{
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
@ -75,13 +104,21 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return array data which can be serialized by <b>json_encode</b>,
|
||||
* which is a value of any type other than a resource.
|
||||
* @since 5.4.0
|
||||
*/
|
||||
public function isOrphan(): bool
|
||||
{
|
||||
return $this->shortUrl === null;
|
||||
}
|
||||
|
||||
public function visitedUrl(): ?string
|
||||
{
|
||||
return $this->visitedUrl;
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
|
57
module/Core/src/ErrorHandler/Model/NotFoundType.php
Normal file
57
module/Core/src/ErrorHandler/Model/NotFoundType.php
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ErrorHandler\Model;
|
||||
|
||||
use Mezzio\Router\RouteResult;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
|
||||
use function rtrim;
|
||||
|
||||
class NotFoundType
|
||||
{
|
||||
private string $type;
|
||||
|
||||
private function __construct(string $type)
|
||||
{
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
|
||||
{
|
||||
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
|
||||
if ($isBaseUrl) {
|
||||
return new self(Visit::TYPE_BASE_URL);
|
||||
}
|
||||
|
||||
/** @var RouteResult $routeResult */
|
||||
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||
if ($routeResult->isFailure()) {
|
||||
return new self(Visit::TYPE_REGULAR_404);
|
||||
}
|
||||
|
||||
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
|
||||
return new self(Visit::TYPE_INVALID_SHORT_URL);
|
||||
}
|
||||
|
||||
return new self(self::class);
|
||||
}
|
||||
|
||||
public function isBaseUrl(): bool
|
||||
{
|
||||
return $this->type === Visit::TYPE_BASE_URL;
|
||||
}
|
||||
|
||||
public function isRegularNotFound(): bool
|
||||
{
|
||||
return $this->type === Visit::TYPE_REGULAR_404;
|
||||
}
|
||||
|
||||
public function isInvalidShortUrl(): bool
|
||||
{
|
||||
return $this->type === Visit::TYPE_INVALID_SHORT_URL;
|
||||
}
|
||||
}
|
|
@ -4,67 +4,48 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Mezzio\Router\RouteResult;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
|
||||
use function rtrim;
|
||||
|
||||
class NotFoundRedirectHandler implements MiddlewareInterface
|
||||
{
|
||||
private Options\NotFoundRedirectOptions $redirectOptions;
|
||||
private RedirectResponseHelperInterface $redirectResponseHelper;
|
||||
private string $shlinkBasePath;
|
||||
|
||||
public function __construct(
|
||||
Options\NotFoundRedirectOptions $redirectOptions,
|
||||
RedirectResponseHelperInterface $redirectResponseHelper,
|
||||
string $shlinkBasePath
|
||||
RedirectResponseHelperInterface $redirectResponseHelper
|
||||
) {
|
||||
$this->redirectOptions = $redirectOptions;
|
||||
$this->shlinkBasePath = $shlinkBasePath;
|
||||
$this->redirectResponseHelper = $redirectResponseHelper;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
/** @var RouteResult $routeResult */
|
||||
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
|
||||
/** @var NotFoundType $notFoundType */
|
||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||
|
||||
return $redirectResponse ?? $handler->handle($request);
|
||||
}
|
||||
|
||||
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface
|
||||
{
|
||||
$isBaseUrl = rtrim($uri->getPath(), '/') === $this->shlinkBasePath;
|
||||
|
||||
if ($isBaseUrl && $this->redirectOptions->hasBaseUrlRedirect()) {
|
||||
if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
|
||||
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
|
||||
}
|
||||
|
||||
if (!$isBaseUrl && $routeResult->isFailure() && $this->redirectOptions->hasRegular404Redirect()) {
|
||||
if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
|
||||
return $this->redirectResponseHelper->buildRedirectResponse(
|
||||
$this->redirectOptions->getRegular404Redirect(),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$routeResult->isSuccess() &&
|
||||
$routeResult->getMatchedRouteName() === RedirectAction::class &&
|
||||
$this->redirectOptions->hasInvalidShortUrlRedirect()
|
||||
) {
|
||||
if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
|
||||
return $this->redirectResponseHelper->buildRedirectResponse(
|
||||
$this->redirectOptions->getInvalidShortUrlRedirect(),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Core\ErrorHandler;
|
|||
use Closure;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Mezzio\Router\RouteResult;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
|
||||
use function file_get_contents;
|
||||
use function sprintf;
|
||||
|
@ -29,11 +29,11 @@ class NotFoundTemplateHandler implements RequestHandlerInterface
|
|||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
/** @var RouteResult $routeResult */
|
||||
$routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
|
||||
/** @var NotFoundType $notFoundType */
|
||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||
$status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
|
||||
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
|
||||
$template = $notFoundType->isInvalidShortUrl() ? self::INVALID_SHORT_CODE_TEMPLATE : self::NOT_FOUND_TEMPLATE;
|
||||
$templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template));
|
||||
return new Response\HtmlResponse($templateContent, $status);
|
||||
}
|
||||
|
|
40
module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php
Normal file
40
module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
||||
|
||||
class NotFoundTrackerMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
/** @var NotFoundType $notFoundType */
|
||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||
$visitor = Visitor::fromRequest($request);
|
||||
|
||||
if ($notFoundType->isBaseUrl()) {
|
||||
$this->visitsTracker->trackBaseUrlVisit($visitor);
|
||||
} elseif ($notFoundType->isRegularNotFound()) {
|
||||
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
|
||||
} elseif ($notFoundType->isInvalidShortUrl()) {
|
||||
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
|
||||
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private string $shlinkBasePath;
|
||||
|
||||
public function __construct(string $shlinkBasePath)
|
||||
{
|
||||
$this->shlinkBasePath = $shlinkBasePath;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$notFoundType = NotFoundType::fromRequest($request, $this->shlinkBasePath);
|
||||
return $handler->handle($request->withAttribute(NotFoundType::class, $notFoundType));
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
|
||||
|
||||
final class ShortUrlVisited extends AbstractVisitEvent
|
||||
final class UrlVisited extends AbstractVisitEvent
|
||||
{
|
||||
private ?string $originalIpAddress;
|
||||
|
|
@ -11,7 +11,7 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
|||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
@ -19,7 +19,7 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
class LocateShortUrlVisit
|
||||
class LocateVisit
|
||||
{
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
private EntityManagerInterface $em;
|
||||
|
@ -41,7 +41,7 @@ class LocateShortUrlVisit
|
|||
$this->eventDispatcher = $eventDispatcher;
|
||||
}
|
||||
|
||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||
public function __invoke(UrlVisited $shortUrlVisited): void
|
||||
{
|
||||
$visitId = $shortUrlVisited->visitId();
|
||||
|
|
@ -10,8 +10,11 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Throwable;
|
||||
|
||||
use function Functional\each;
|
||||
|
||||
class NotifyVisitToMercure
|
||||
{
|
||||
private PublisherInterface $publisher;
|
||||
|
@ -45,12 +48,26 @@ class NotifyVisitToMercure
|
|||
}
|
||||
|
||||
try {
|
||||
($this->publisher)($this->updatesGenerator->newShortUrlVisitUpdate($visit));
|
||||
($this->publisher)($this->updatesGenerator->newVisitUpdate($visit));
|
||||
each($this->determineUpdatesForVisit($visit), fn (Update $update) => ($this->publisher)($update));
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
|
||||
'e' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Update[]
|
||||
*/
|
||||
private function determineUpdatesForVisit(Visit $visit): array
|
||||
{
|
||||
if ($visit->isOrphan()) {
|
||||
return [$this->updatesGenerator->newOrphanVisitUpdate($visit)];
|
||||
}
|
||||
|
||||
return [
|
||||
$this->updatesGenerator->newShortUrlVisitUpdate($visit),
|
||||
$this->updatesGenerator->newVisitUpdate($visit),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ use Fig\Http\Message\RequestMethodInterface;
|
|||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use GuzzleHttp\Promise\Utils;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
|
@ -20,7 +21,6 @@ use Throwable;
|
|||
|
||||
use function Functional\map;
|
||||
use function Functional\partial_left;
|
||||
use function GuzzleHttp\Promise\settle;
|
||||
|
||||
class NotifyVisitToWebHooks
|
||||
{
|
||||
|
@ -69,7 +69,7 @@ class NotifyVisitToWebHooks
|
|||
$requestPromises = $this->performRequests($requestOptions, $visitId);
|
||||
|
||||
// Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error.
|
||||
settle($requestPromises)->wait();
|
||||
Utils::settle($requestPromises)->wait();
|
||||
}
|
||||
|
||||
private function buildRequestOptions(Visit $visit): array
|
||||
|
|
|
@ -16,29 +16,41 @@ use const JSON_THROW_ON_ERROR;
|
|||
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
|
||||
{
|
||||
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
|
||||
private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit';
|
||||
|
||||
private DataTransformerInterface $transformer;
|
||||
private DataTransformerInterface $shortUrlTransformer;
|
||||
private DataTransformerInterface $orphanVisitTransformer;
|
||||
|
||||
public function __construct(DataTransformerInterface $transformer)
|
||||
{
|
||||
$this->transformer = $transformer;
|
||||
public function __construct(
|
||||
DataTransformerInterface $shortUrlTransformer,
|
||||
DataTransformerInterface $orphanVisitTransformer
|
||||
) {
|
||||
$this->shortUrlTransformer = $shortUrlTransformer;
|
||||
$this->orphanVisitTransformer = $orphanVisitTransformer;
|
||||
}
|
||||
|
||||
public function newVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return new Update(self::NEW_VISIT_TOPIC, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
public function newOrphanVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return new Update(self::NEW_ORPHAN_VISIT_TOPIC, $this->serialize([
|
||||
'visit' => $this->orphanVisitTransformer->transform($visit),
|
||||
]));
|
||||
}
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
$shortUrl = $visit->getShortUrl();
|
||||
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode());
|
||||
|
||||
return new Update($topic, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
|
|
@ -11,5 +11,7 @@ interface MercureUpdatesGeneratorInterface
|
|||
{
|
||||
public function newVisitUpdate(Visit $visit): Update;
|
||||
|
||||
public function newOrphanVisitUpdate(Visit $visit): Update;
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update;
|
||||
}
|
||||
|
|
|
@ -14,15 +14,18 @@ final class Visitor
|
|||
public const USER_AGENT_MAX_LENGTH = 512;
|
||||
public const REFERER_MAX_LENGTH = 1024;
|
||||
public const REMOTE_ADDRESS_MAX_LENGTH = 256;
|
||||
public const VISITED_URL_MAX_LENGTH = 2048;
|
||||
|
||||
private string $userAgent;
|
||||
private string $referer;
|
||||
private string $visitedUrl;
|
||||
private ?string $remoteAddress;
|
||||
|
||||
public function __construct(string $userAgent, string $referer, ?string $remoteAddress)
|
||||
public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl)
|
||||
{
|
||||
$this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH);
|
||||
$this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH);
|
||||
$this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH);
|
||||
$this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH);
|
||||
}
|
||||
|
||||
|
@ -37,12 +40,13 @@ final class Visitor
|
|||
$request->getHeaderLine('User-Agent'),
|
||||
$request->getHeaderLine('Referer'),
|
||||
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
|
||||
$request->getUri()->__toString(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function emptyInstance(): self
|
||||
{
|
||||
return new self('', '', null);
|
||||
return new self('', '', null, '');
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
|
@ -59,4 +63,9 @@ final class Visitor
|
|||
{
|
||||
return $this->remoteAddress;
|
||||
}
|
||||
|
||||
public function getVisitedUrl(): string
|
||||
{
|
||||
return $this->visitedUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
|
|||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
use function Shlinkio\Shlink\Core\parseDateFromQuery;
|
||||
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
|
||||
|
||||
final class VisitsParams
|
||||
{
|
||||
|
@ -36,7 +36,7 @@ final class VisitsParams
|
|||
public static function fromRawData(array $query): self
|
||||
{
|
||||
return new self(
|
||||
new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')),
|
||||
parseDateRangeFromQuery($query, 'startDate', 'endDate'),
|
||||
(int) ($query['page'] ?? 1),
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||
);
|
||||
|
|
|
@ -19,6 +19,8 @@ class UrlShortenerOptions extends AbstractOptions
|
|||
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
|
||||
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
private bool $autoResolveTitles = false;
|
||||
private bool $anonymizeRemoteAddr = true;
|
||||
private bool $trackOrphanVisits = true;
|
||||
|
||||
public function isUrlValidationEnabled(): bool
|
||||
{
|
||||
|
@ -62,9 +64,28 @@ class UrlShortenerOptions extends AbstractOptions
|
|||
return $this->autoResolveTitles;
|
||||
}
|
||||
|
||||
protected function setAutoResolveTitles(bool $autoResolveTitles): self
|
||||
protected function setAutoResolveTitles(bool $autoResolveTitles): void
|
||||
{
|
||||
$this->autoResolveTitles = $autoResolveTitles;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function anonymizeRemoteAddr(): bool
|
||||
{
|
||||
return $this->anonymizeRemoteAddr;
|
||||
}
|
||||
|
||||
protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
|
||||
{
|
||||
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
|
||||
}
|
||||
|
||||
public function trackOrphanVisits(): bool
|
||||
{
|
||||
return $this->trackOrphanVisits;
|
||||
}
|
||||
|
||||
protected function setTrackOrphanVisits(bool $trackOrphanVisits): void
|
||||
{
|
||||
$this->trackOrphanVisits = $trackOrphanVisits;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $repo;
|
||||
private VisitsParams $params;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $repo, VisitsParams $params)
|
||||
{
|
||||
$this->repo = $repo;
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->repo->countOrphanVisits($this->params->getDateRange());
|
||||
}
|
||||
|
||||
public function getSlice($offset, $length): iterable // phpcs:ignore
|
||||
{
|
||||
return $this->repo->findOrphanVisits($this->params->getDateRange(), $length, $offset);
|
||||
}
|
||||
}
|
|
@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Repository;
|
|||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
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 Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits;
|
||||
use Shlinkio\Shlink\Core\Visit\Spec\CountOfShortUrlVisits;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
@ -168,6 +168,29 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
return $qb;
|
||||
}
|
||||
|
||||
public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as 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')
|
||||
->where($qb->expr()->isNull('v.shortUrl'));
|
||||
|
||||
$this->applyDatesInline($qb, $dateRange);
|
||||
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countOrphanVisits(?DateRange $dateRange = null): int
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($dateRange));
|
||||
}
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey));
|
||||
}
|
||||
|
||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||
{
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
|
@ -208,11 +231,4 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
|
||||
return $query->getResult();
|
||||
}
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(
|
||||
Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,5 +62,12 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
|
|||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array;
|
||||
|
||||
public function countOrphanVisits(?DateRange $dateRange = null): int;
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int;
|
||||
}
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\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\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
{
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private EventDispatcherInterface $eventDispatcher;
|
||||
private bool $anonymizeRemoteAddr;
|
||||
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
bool $anonymizeRemoteAddr
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
|
||||
}
|
||||
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void
|
||||
{
|
||||
$visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr);
|
||||
|
||||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
$spec = $apiKey !== null ? $apiKey->spec() : null;
|
||||
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec));
|
||||
$paginator->setMaxPerPage($params->getItemsPerPage())
|
||||
->setCurrentPage($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
if (! $tagRepo->tagExists($tag, $apiKey)) {
|
||||
throw TagNotFoundException::fromTag($tag);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey));
|
||||
$paginator->setMaxPerPage($params->getItemsPerPage())
|
||||
->setCurrentPage($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Shlinkio\Shlink\Common\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;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
}
|
38
module/Core/src/Spec/InDateRange.php
Normal file
38
module/Core/src/Spec/InDateRange.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
class InDateRange extends BaseSpecification
|
||||
{
|
||||
private ?DateRange $dateRange;
|
||||
private string $field;
|
||||
|
||||
public function __construct(?DateRange $dateRange, string $field = 'date')
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dateRange = $dateRange;
|
||||
$this->field = $field;
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
$criteria = [];
|
||||
|
||||
if ($this->dateRange !== null && $this->dateRange->getStartDate() !== null) {
|
||||
$criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString());
|
||||
}
|
||||
|
||||
if ($this->dateRange !== null && $this->dateRange->getEndDate() !== null) {
|
||||
$criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString());
|
||||
}
|
||||
|
||||
return Spec::andX(...$criteria);
|
||||
}
|
||||
}
|
|
@ -9,16 +9,19 @@ use JsonSerializable;
|
|||
final class VisitsStats implements JsonSerializable
|
||||
{
|
||||
private int $visitsCount;
|
||||
private int $orphanVisitsCount;
|
||||
|
||||
public function __construct(int $visitsCount)
|
||||
public function __construct(int $visitsCount, int $orphanVisitsCount)
|
||||
{
|
||||
$this->visitsCount = $visitsCount;
|
||||
$this->orphanVisitsCount = $orphanVisitsCount;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'visitsCount' => $this->visitsCount,
|
||||
'orphanVisitsCount' => $this->orphanVisitsCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
30
module/Core/src/Visit/Spec/CountOfOrphanVisits.php
Normal file
30
module/Core/src/Visit/Spec/CountOfOrphanVisits.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Spec\InDateRange;
|
||||
|
||||
class CountOfOrphanVisits extends BaseSpecification
|
||||
{
|
||||
private ?DateRange $dateRange;
|
||||
|
||||
public function __construct(?DateRange $dateRange)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dateRange = $dateRange;
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
return Spec::countOf(Spec::andX(
|
||||
Spec::isNull('shortUrl'),
|
||||
new InDateRange($this->dateRange),
|
||||
));
|
||||
}
|
||||
}
|
30
module/Core/src/Visit/Spec/CountOfShortUrlVisits.php
Normal file
30
module/Core/src/Visit/Spec/CountOfShortUrlVisits.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class CountOfShortUrlVisits extends BaseSpecification
|
||||
{
|
||||
private ?ApiKey $apiKey;
|
||||
|
||||
public function __construct(?ApiKey $apiKey)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
return Spec::countOf(Spec::andX(
|
||||
Spec::isNotNull('shortUrl'),
|
||||
new WithApiKeySpecsEnsuringJoin($this->apiKey, 'shortUrl'),
|
||||
));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
|
||||
class OrphanVisitDataTransformer implements DataTransformerInterface
|
||||
{
|
||||
/**
|
||||
* @param Visit $visit
|
||||
* @return array
|
||||
*/
|
||||
public function transform($visit): array // phpcs:ignore
|
||||
{
|
||||
$serializedVisit = $visit->jsonSerialize();
|
||||
$serializedVisit['visitedUrl'] = $visit->visitedUrl();
|
||||
$serializedVisit['type'] = $visit->type();
|
||||
|
||||
return $serializedVisit;
|
||||
}
|
||||
}
|
|
@ -5,8 +5,22 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
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\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
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\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
|
@ -20,14 +34,71 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||
}
|
||||
|
||||
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats
|
||||
{
|
||||
return new VisitsStats($this->getVisitsCount($apiKey));
|
||||
}
|
||||
|
||||
private function getVisitsCount(?ApiKey $apiKey): int
|
||||
{
|
||||
/** @var VisitRepository $visitsRepo */
|
||||
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||
return $visitsRepo->countVisits($apiKey);
|
||||
|
||||
return new VisitsStats($visitsRepo->countVisits($apiKey), $visitsRepo->countOrphanVisits());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function visitsForShortUrl(
|
||||
ShortUrlIdentifier $identifier,
|
||||
VisitsParams $params,
|
||||
?ApiKey $apiKey = null
|
||||
): Paginator {
|
||||
$spec = $apiKey !== null ? $apiKey->spec() : null;
|
||||
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
if (! $tagRepo->tagExists($tag, $apiKey)) {
|
||||
throw TagNotFoundException::fromTag($tag);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
*/
|
||||
public function orphanVisits(VisitsParams $params): Paginator
|
||||
{
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params);
|
||||
}
|
||||
|
||||
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
|
||||
{
|
||||
$paginator = new Paginator($adapter);
|
||||
$paginator->setMaxPerPage($params->getItemsPerPage())
|
||||
->setCurrentPage($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,37 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
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\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsStatsHelperInterface
|
||||
{
|
||||
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function visitsForShortUrl(
|
||||
ShortUrlIdentifier $identifier,
|
||||
VisitsParams $params,
|
||||
?ApiKey $apiKey = null
|
||||
): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
*/
|
||||
public function orphanVisits(VisitsParams $params): Paginator;
|
||||
}
|
||||
|
|
73
module/Core/src/Visit/VisitsTracker.php
Normal file
73
module/Core/src/Visit/VisitsTracker.php
Normal file
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
{
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private EventDispatcherInterface $eventDispatcher;
|
||||
private UrlShortenerOptions $options;
|
||||
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
UrlShortenerOptions $options
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void
|
||||
{
|
||||
$this->trackVisit(
|
||||
Visit::forValidShortUrl($shortUrl, $visitor, $this->options->anonymizeRemoteAddr()),
|
||||
$visitor,
|
||||
);
|
||||
}
|
||||
|
||||
public function trackInvalidShortUrlVisit(Visitor $visitor): void
|
||||
{
|
||||
if (! $this->options->trackOrphanVisits()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->options->anonymizeRemoteAddr()), $visitor);
|
||||
}
|
||||
|
||||
public function trackBaseUrlVisit(Visitor $visitor): void
|
||||
{
|
||||
if (! $this->options->trackOrphanVisits()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->trackVisit(Visit::forBasePath($visitor, $this->options->anonymizeRemoteAddr()), $visitor);
|
||||
}
|
||||
|
||||
public function trackRegularNotFoundVisit(Visitor $visitor): void
|
||||
{
|
||||
if (! $this->options->trackOrphanVisits()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->trackVisit(Visit::forRegularNotFound($visitor, $this->options->anonymizeRemoteAddr()), $visitor);
|
||||
}
|
||||
|
||||
private function trackVisit(Visit $visit, Visitor $visitor): void
|
||||
{
|
||||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress()));
|
||||
}
|
||||
}
|
19
module/Core/src/Visit/VisitsTrackerInterface.php
Normal file
19
module/Core/src/Visit/VisitsTrackerInterface.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
|
||||
|
||||
public function trackInvalidShortUrlVisit(Visitor $visitor): void;
|
||||
|
||||
public function trackBaseUrlVisit(Visitor $visitor): void;
|
||||
|
||||
public function trackRegularNotFoundVisit(Visitor $visitor): void;
|
||||
}
|
|
@ -95,7 +95,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
|||
$this->getEntityManager()->persist($foo);
|
||||
|
||||
$bar = ShortUrl::withLongUrl('bar');
|
||||
$visit = new Visit($bar, Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl($bar, Visitor::emptyInstance());
|
||||
$this->getEntityManager()->persist($visit);
|
||||
$bar->setVisits(new ArrayCollection([$visit]));
|
||||
$this->getEntityManager()->persist($bar);
|
||||
|
|
|
@ -64,13 +64,13 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
|
||||
$shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags), $this->relationResolver);
|
||||
$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()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
|
||||
|
||||
$shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findTagsWithInfo();
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
|
@ -52,7 +53,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
};
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
if ($i >= 2) {
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
@ -168,7 +169,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function countReturnsExpectedResultBasedOnApiKey(): void
|
||||
public function countVisitsReturnsExpectedResultBasedOnApiKey(): void
|
||||
{
|
||||
$domain = new Domain('foo.com');
|
||||
$this->getEntityManager()->persist($domain);
|
||||
|
@ -200,12 +201,87 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain));
|
||||
$this->getEntityManager()->persist($domainApiKey);
|
||||
|
||||
// Visits not linked to any short URL
|
||||
$this->getEntityManager()->persist(Visit::forBasePath(Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::emptyInstance()));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(4 + 5 + 7, $this->repo->countVisits());
|
||||
self::assertEquals(4, $this->repo->countVisits($apiKey1));
|
||||
self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2));
|
||||
self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
|
||||
self::assertEquals(3, $this->repo->countOrphanVisits());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findOrphanVisitsReturnsExpectedResult(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '']));
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->createVisitsForShortUrl($shortUrl, 7);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forBasePath(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forRegularNotFound(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
}
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertCount(18, $this->repo->findOrphanVisits());
|
||||
self::assertCount(5, $this->repo->findOrphanVisits(null, 5));
|
||||
self::assertCount(10, $this->repo->findOrphanVisits(null, 15, 8));
|
||||
self::assertCount(9, $this->repo->findOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')), 15));
|
||||
self::assertCount(2, $this->repo->findOrphanVisits(
|
||||
DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')),
|
||||
6,
|
||||
4,
|
||||
));
|
||||
self::assertCount(3, $this->repo->findOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01'))));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countOrphanVisitsReturnsExpectedResult(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '']));
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->createVisitsForShortUrl($shortUrl, 7);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forBasePath(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
Visit::forRegularNotFound(Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
}
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(18, $this->repo->countOrphanVisits());
|
||||
self::assertEquals(18, $this->repo->countOrphanVisits(DateRange::emptyInstance()));
|
||||
self::assertEquals(9, $this->repo->countOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04'))));
|
||||
self::assertEquals(6, $this->repo->countOrphanVisits(
|
||||
DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')),
|
||||
));
|
||||
self::assertEquals(3, $this->repo->countOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01'))));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array
|
||||
|
@ -237,13 +313,22 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void
|
||||
{
|
||||
for ($i = 0; $i < $amount; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrl,
|
||||
Visitor::emptyInstance(),
|
||||
true,
|
||||
$visit = $this->setDateOnVisit(
|
||||
Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()),
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
}
|
||||
|
||||
private function setDateOnVisit(Visit $visit, Chronos $date): Visit
|
||||
{
|
||||
$ref = new ReflectionObject($visit);
|
||||
$dateProp = $ref->getProperty('date');
|
||||
$dateProp->setAccessible(true);
|
||||
$dateProp->setValue($visit, $date);
|
||||
|
||||
return $visit;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTracker;
|
||||
|
||||
class PixelActionTest extends TestCase
|
||||
{
|
||||
|
|
|
@ -19,8 +19,8 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
||||
|
||||
use function array_key_exists;
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
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;
|
||||
|
@ -13,35 +12,30 @@ use Shlinkio\Shlink\Core\Model\Visitor;
|
|||
|
||||
class VisitTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDates
|
||||
*/
|
||||
public function isProperlyJsonSerialized(?Chronos $date): void
|
||||
/** @test */
|
||||
public function isProperlyJsonSerialized(): void
|
||||
{
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date);
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4', ''));
|
||||
|
||||
self::assertEquals([
|
||||
'referer' => 'some site',
|
||||
'date' => ($date ?? $visit->getDate())->toAtomString(),
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
'userAgent' => 'Chrome',
|
||||
'visitLocation' => null,
|
||||
], $visit->jsonSerialize());
|
||||
}
|
||||
|
||||
public function provideDates(): iterable
|
||||
{
|
||||
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(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', $address), $anonymize);
|
||||
$visit = Visit::forValidShortUrl(
|
||||
ShortUrl::createEmpty(),
|
||||
new Visitor('Chrome', 'some site', $address, ''),
|
||||
$anonymize,
|
||||
);
|
||||
|
||||
self::assertEquals($expectedAddress, $visit->getRemoteAddr());
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ use Psr\Http\Message\ServerRequestInterface;
|
|||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
|
@ -33,7 +34,7 @@ class NotFoundRedirectHandlerTest extends TestCase
|
|||
{
|
||||
$this->redirectOptions = new NotFoundRedirectOptions();
|
||||
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
|
||||
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal(), '');
|
||||
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -64,19 +65,19 @@ class NotFoundRedirectHandlerTest extends TestCase
|
|||
public function provideRedirects(): iterable
|
||||
{
|
||||
yield 'base URL with trailing slash' => [
|
||||
ServerRequestFactory::fromGlobals()->withUri(new Uri('/')),
|
||||
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))),
|
||||
'baseUrl',
|
||||
];
|
||||
yield 'base URL without trailing slash' => [
|
||||
ServerRequestFactory::fromGlobals()->withUri(new Uri('')),
|
||||
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))),
|
||||
'baseUrl',
|
||||
];
|
||||
yield 'regular 404' => [
|
||||
ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar')),
|
||||
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))),
|
||||
'regular404',
|
||||
];
|
||||
yield 'invalid short URL' => [
|
||||
ServerRequestFactory::fromGlobals()
|
||||
$this->withNotFoundType(ServerRequestFactory::fromGlobals()
|
||||
->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRoute(
|
||||
|
@ -88,7 +89,7 @@ class NotFoundRedirectHandlerTest extends TestCase
|
|||
),
|
||||
),
|
||||
)
|
||||
->withUri(new Uri('/abc123')),
|
||||
->withUri(new Uri('/abc123'))),
|
||||
'invalidShortUrl',
|
||||
];
|
||||
}
|
||||
|
@ -96,7 +97,7 @@ class NotFoundRedirectHandlerTest extends TestCase
|
|||
/** @test */
|
||||
public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void
|
||||
{
|
||||
$req = ServerRequestFactory::fromGlobals();
|
||||
$req = $this->withNotFoundType(ServerRequestFactory::fromGlobals());
|
||||
$resp = new Response();
|
||||
|
||||
$buildResp = $this->helper->buildRedirectResponse(Argument::cetera());
|
||||
|
@ -110,4 +111,10 @@ class NotFoundRedirectHandlerTest extends TestCase
|
|||
$buildResp->shouldNotHaveBeenCalled();
|
||||
$handle->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface
|
||||
{
|
||||
$type = NotFoundType::fromRequest($req, '');
|
||||
return $req->withAttribute(NotFoundType::class, $type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,30 +4,31 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Closure;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Mezzio\Router\Route;
|
||||
use Mezzio\Router\RouteResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler;
|
||||
|
||||
class NotFoundTemplateHandlerTest extends TestCase
|
||||
{
|
||||
private NotFoundTemplateHandler $handler;
|
||||
private Closure $readFile;
|
||||
private bool $readFileCalled;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->readFileCalled = false;
|
||||
$this->readFile = function (string $fileName): string {
|
||||
$readFile = function (string $fileName): string {
|
||||
$this->readFileCalled = true;
|
||||
return $fileName;
|
||||
};
|
||||
$this->handler = new NotFoundTemplateHandler($this->readFile);
|
||||
$this->handler = new NotFoundTemplateHandler($readFile);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -45,15 +46,29 @@ class NotFoundTemplateHandlerTest extends TestCase
|
|||
|
||||
public function provideTemplates(): iterable
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo'));
|
||||
|
||||
yield [$request, NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
|
||||
yield [
|
||||
$request->withAttribute(
|
||||
yield 'base url' => [$this->withNotFoundType($request, '/foo'), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
|
||||
yield 'regular not found' => [$this->withNotFoundType($request), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
|
||||
yield 'invalid short code' => [
|
||||
$this->withNotFoundType($request->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal())),
|
||||
),
|
||||
RouteResult::fromRoute(
|
||||
new Route(
|
||||
'',
|
||||
$this->prophesize(MiddlewareInterface::class)->reveal(),
|
||||
['GET'],
|
||||
RedirectAction::class,
|
||||
),
|
||||
),
|
||||
)),
|
||||
NotFoundTemplateHandler::INVALID_SHORT_CODE_TEMPLATE,
|
||||
];
|
||||
}
|
||||
|
||||
private function withNotFoundType(ServerRequestInterface $req, string $baseUrl = ''): ServerRequestInterface
|
||||
{
|
||||
$type = NotFoundType::fromRequest($req, $baseUrl);
|
||||
return $req->withAttribute(NotFoundType::class, $type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTrackerMiddleware;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
||||
|
||||
class NotFoundTrackerMiddlewareTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private NotFoundTrackerMiddleware $middleware;
|
||||
private ServerRequestInterface $request;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
private ObjectProphecy $notFoundType;
|
||||
private ObjectProphecy $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->notFoundType = $this->prophesize(NotFoundType::class);
|
||||
$this->handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
$this->handler->handle(Argument::cetera())->willReturn(new Response());
|
||||
|
||||
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
|
||||
$this->middleware = new NotFoundTrackerMiddleware($this->visitsTracker->reveal());
|
||||
|
||||
$this->request = ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
NotFoundType::class,
|
||||
$this->notFoundType->reveal(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function baseUrlErrorIsTracked(): void
|
||||
{
|
||||
$isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true);
|
||||
$isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false);
|
||||
$isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false);
|
||||
|
||||
$this->middleware->process($this->request, $this->handler->reveal());
|
||||
|
||||
$isBaseUrl->shouldHaveBeenCalledOnce();
|
||||
$isRegularNotFound->shouldNotHaveBeenCalled();
|
||||
$isInvalidShortUrl->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce();
|
||||
$this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function regularNotFoundErrorIsTracked(): void
|
||||
{
|
||||
$isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false);
|
||||
$isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true);
|
||||
$isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false);
|
||||
|
||||
$this->middleware->process($this->request, $this->handler->reveal());
|
||||
|
||||
$isBaseUrl->shouldHaveBeenCalledOnce();
|
||||
$isRegularNotFound->shouldHaveBeenCalledOnce();
|
||||
$isInvalidShortUrl->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce();
|
||||
$this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function invalidShortUrlErrorIsTracked(): void
|
||||
{
|
||||
$isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false);
|
||||
$isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false);
|
||||
$isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true);
|
||||
|
||||
$this->middleware->process($this->request, $this->handler->reveal());
|
||||
|
||||
$isBaseUrl->shouldHaveBeenCalledOnce();
|
||||
$isRegularNotFound->shouldHaveBeenCalledOnce();
|
||||
$isInvalidShortUrl->shouldHaveBeenCalledOnce();
|
||||
$this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled();
|
||||
$this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTypeResolverMiddleware;
|
||||
|
||||
class NotFoundTypeResolverMiddlewareTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private NotFoundTypeResolverMiddleware $middleware;
|
||||
private ObjectProphecy $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new NotFoundTypeResolverMiddleware('');
|
||||
$this->handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notFoundTypeIsAddedToRequest(): void
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
$handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) {
|
||||
Assert::assertArrayHasKey(NotFoundType::class, $req->getAttributes());
|
||||
|
||||
return true;
|
||||
}))->willReturn(new Response());
|
||||
|
||||
$this->middleware->process($request, $this->handler->reveal());
|
||||
|
||||
self::assertArrayNotHasKey(NotFoundType::class, $request->getAttributes());
|
||||
$handle->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -17,19 +17,19 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
|
|||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
|
||||
class LocateShortUrlVisitTest extends TestCase
|
||||
class LocateVisitTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private LocateShortUrlVisit $locateVisit;
|
||||
private LocateVisit $locateVisit;
|
||||
private ObjectProphecy $ipLocationResolver;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $logger;
|
||||
|
@ -44,7 +44,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->locateVisit = new LocateShortUrlVisit(
|
||||
$this->locateVisit = new LocateVisit(
|
||||
$this->ipLocationResolver->reveal(),
|
||||
$this->em->reveal(),
|
||||
$this->logger->reveal(),
|
||||
|
@ -56,7 +56,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
/** @test */
|
||||
public function invalidVisitLogsWarning(): void
|
||||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new UrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn(null);
|
||||
$logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
||||
'visitId' => 123,
|
||||
|
@ -76,9 +76,9 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
/** @test */
|
||||
public function invalidAddressLogsWarning(): void
|
||||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new UrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn(
|
||||
new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')),
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
|
||||
);
|
||||
$resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow(
|
||||
WrongIpException::class,
|
||||
|
@ -105,7 +105,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
*/
|
||||
public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void
|
||||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new UrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
});
|
||||
|
@ -127,21 +127,20 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
|
||||
yield 'null IP' => [new Visit($shortUrl, new Visitor('', '', null))];
|
||||
yield 'empty IP' => [new Visit($shortUrl, new Visitor('', '', ''))];
|
||||
yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))];
|
||||
yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))];
|
||||
yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))];
|
||||
yield 'localhost' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', IpAddress::LOCALHOST, ''))];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideIpAddresses
|
||||
*/
|
||||
public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void
|
||||
public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void
|
||||
{
|
||||
$ipAddr = $originalIpAddress ?? $anonymizedIpAddress;
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr));
|
||||
$ipAddr = $originalIpAddress ?? $visit->getRemoteAddr();
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123', $originalIpAddress);
|
||||
$event = new UrlVisited('123', $originalIpAddress);
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
|
@ -162,8 +161,17 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
|
||||
public function provideIpAddresses(): iterable
|
||||
{
|
||||
yield 'no original IP address' => ['1.2.3.0', null];
|
||||
yield 'original IP address' => ['1.2.3.0', '1.2.3.4'];
|
||||
yield 'no original IP address' => [
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
|
||||
null,
|
||||
];
|
||||
yield 'original IP address' => [
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
|
||||
'1.2.3.4',
|
||||
];
|
||||
yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
|
||||
yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
|
||||
yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -171,9 +179,9 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
{
|
||||
$e = GeolocationDbUpdateFailedException::withOlderDb();
|
||||
$ipAddr = '1.2.3.0';
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, ''));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new UrlVisited('123');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
|
@ -202,9 +210,9 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
{
|
||||
$e = GeolocationDbUpdateFailedException::withoutOlderDb();
|
||||
$ipAddr = '1.2.3.0';
|
||||
$visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, ''));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new UrlVisited('123');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
|
@ -57,10 +57,9 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
$logDebug = $this->logger->debug(Argument::cetera());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate(
|
||||
Argument::type(Visit::class),
|
||||
)->willReturn(new Update('', ''));
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class))->willReturn(
|
||||
new Update('', ''),
|
||||
);
|
||||
$buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class));
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class));
|
||||
$publish = $this->publisher->__invoke(Argument::type(Update::class));
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
@ -70,6 +69,7 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
|
@ -77,13 +77,14 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
public function notificationsAreSentWhenVisitIsFound(): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance());
|
||||
$update = new Update('', '');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logDebug = $this->logger->debug(Argument::cetera());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update);
|
||||
|
||||
|
@ -94,6 +95,7 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
|
@ -101,7 +103,7 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
public function debugIsLoggedWhenExceptionIsThrown(): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance());
|
||||
$update = new Update('', '');
|
||||
$e = new RuntimeException('Error');
|
||||
|
||||
|
@ -111,6 +113,7 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
'e' => $e,
|
||||
]);
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update)->willThrow($e);
|
||||
|
||||
|
@ -120,7 +123,45 @@ class NotifyVisitToMercureTest extends TestCase
|
|||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldHaveBeenCalledOnce();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideOrphanVisits
|
||||
*/
|
||||
public function notificationsAreSentForOrphanVisits(Visit $visit): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$update = new Update('', '');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logDebug = $this->logger->debug(Argument::cetera());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update);
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewOrphanVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$publish->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideOrphanVisits(): iterable
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
|
||||
yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)];
|
||||
yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)];
|
||||
yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ class NotifyVisitToWebHooksTest extends TestCase
|
|||
$invalidWebhooks = ['invalid', 'baz'];
|
||||
|
||||
$find = $this->em->find(Visit::class, '1')->willReturn(
|
||||
new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()),
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()),
|
||||
);
|
||||
$requestAsync = $this->httpClient->requestAsync(
|
||||
RequestMethodInterface::METHOD_POST,
|
||||
|
|
|
@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
|||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer;
|
||||
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
|
||||
|
@ -21,7 +22,10 @@ class MercureUpdatesGeneratorTest extends TestCase
|
|||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->generator = new MercureUpdatesGenerator(new ShortUrlDataTransformer(new ShortUrlStringifier([])));
|
||||
$this->generator = new MercureUpdatesGenerator(
|
||||
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
|
||||
new OrphanVisitDataTransformer(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -35,7 +39,7 @@ class MercureUpdatesGeneratorTest extends TestCase
|
|||
'longUrl' => '',
|
||||
'title' => $title,
|
||||
]));
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
$update = $this->generator->{$method}($visit);
|
||||
|
||||
|
@ -70,4 +74,34 @@ class MercureUpdatesGeneratorTest extends TestCase
|
|||
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit', 'the cool title'];
|
||||
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideOrphanVisits
|
||||
*/
|
||||
public function orphanVisitIsProperlySerializedIntoUpdate(Visit $orphanVisit): void
|
||||
{
|
||||
$update = $this->generator->newOrphanVisitUpdate($orphanVisit);
|
||||
|
||||
self::assertEquals(['https://shlink.io/new-orphan-visit'], $update->getTopics());
|
||||
self::assertEquals([
|
||||
'visit' => [
|
||||
'referer' => '',
|
||||
'userAgent' => '',
|
||||
'visitLocation' => null,
|
||||
'date' => $orphanVisit->getDate()->toAtomString(),
|
||||
'visitedUrl' => $orphanVisit->visitedUrl(),
|
||||
'type' => $orphanVisit->type(),
|
||||
],
|
||||
], json_decode($update->getData()));
|
||||
}
|
||||
|
||||
public function provideOrphanVisits(): iterable
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
|
||||
yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)];
|
||||
yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)];
|
||||
yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ class VisitorTest extends TestCase
|
|||
public function provideParams(): iterable
|
||||
{
|
||||
yield 'all values are bigger' => [
|
||||
[str_repeat('a', 1000), str_repeat('b', 2000), str_repeat('c', 500)],
|
||||
[str_repeat('a', 1000), str_repeat('b', 2000), str_repeat('c', 500), ''],
|
||||
[
|
||||
'userAgent' => str_repeat('a', Visitor::USER_AGENT_MAX_LENGTH),
|
||||
'referer' => str_repeat('b', Visitor::REFERER_MAX_LENGTH),
|
||||
|
@ -39,7 +39,7 @@ class VisitorTest extends TestCase
|
|||
],
|
||||
];
|
||||
yield 'some values are smaller' => [
|
||||
[str_repeat('a', 10), str_repeat('b', 2000), null],
|
||||
[str_repeat('a', 10), str_repeat('b', 2000), null, ''],
|
||||
[
|
||||
'userAgent' => str_repeat('a', 10),
|
||||
'referer' => str_repeat('b', Visitor::REFERER_MAX_LENGTH),
|
||||
|
@ -51,6 +51,7 @@ class VisitorTest extends TestCase
|
|||
$userAgent = $this->generateRandomString(2000),
|
||||
$referer = $this->generateRandomString(50),
|
||||
null,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'userAgent' => substr($userAgent, 0, Visitor::USER_AGENT_MAX_LENGTH),
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class OrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private OrphanVisitsPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
private VisitsParams $params;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->params = VisitsParams::fromRawData([]);
|
||||
$this->adapter = new OrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countDelegatesToRepository(): void
|
||||
{
|
||||
$expectedCount = 5;
|
||||
$repoCount = $this->repo->countOrphanVisits($this->params->getDateRange())->willReturn($expectedCount);
|
||||
|
||||
$result = $this->adapter->getNbResults();
|
||||
|
||||
self::assertEquals($expectedCount, $result);
|
||||
$repoCount->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideLimitAndOffset
|
||||
*/
|
||||
public function getSliceDelegatesToRepository(int $limit, int $offset): void
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
$list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)];
|
||||
$repoFind = $this->repo->findOrphanVisits($this->params->getDateRange(), $limit, $offset)->willReturn($list);
|
||||
|
||||
$result = $this->adapter->getSlice($offset, $limit);
|
||||
|
||||
self::assertEquals($list, $result);
|
||||
$repoFind->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideLimitAndOffset(): iterable
|
||||
{
|
||||
yield [1, 5];
|
||||
yield [10, 4];
|
||||
yield [30, 18];
|
||||
}
|
||||
}
|
|
@ -34,7 +34,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
|||
public function setUp(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection(
|
||||
map(range(0, 10), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())),
|
||||
map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())),
|
||||
));
|
||||
$this->shortCode = $shortUrl->getShortCode();
|
||||
|
||||
|
|
|
@ -121,7 +121,7 @@ class ShortUrlResolverTest extends TestCase
|
|||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => '']));
|
||||
$shortUrl->setVisits(new ArrayCollection(map(
|
||||
range(0, 4),
|
||||
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
|
||||
fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()),
|
||||
)));
|
||||
|
||||
return $shortUrl;
|
||||
|
@ -140,7 +140,7 @@ class ShortUrlResolverTest extends TestCase
|
|||
]));
|
||||
$shortUrl->setVisits(new ArrayCollection(map(
|
||||
range(0, 4),
|
||||
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
|
||||
fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()),
|
||||
)));
|
||||
|
||||
return $shortUrl;
|
||||
|
|
|
@ -1,144 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
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\Event\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;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class VisitsTrackerTest extends TestCase
|
||||
{
|
||||
use ApiKeyHelpersTrait;
|
||||
use ProphecyTrait;
|
||||
|
||||
private VisitsTracker $visitsTracker;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $eventDispatcher;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackPersistsVisit(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
|
||||
$this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track(ShortUrl::withLongUrl($shortCode), Visitor::emptyInstance());
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAdminApiKeys
|
||||
*/
|
||||
public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$spec = $apiKey === null ? null : $apiKey->spec();
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
|
||||
$list,
|
||||
);
|
||||
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey);
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$count->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$apiKey = new ApiKey();
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
$tagExists->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAdminApiKeys
|
||||
*/
|
||||
public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$spec = $apiKey === null ? null : $apiKey->spec();
|
||||
$list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list);
|
||||
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$tagExists->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit\Transformer;
|
||||
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class OrphanVisitDataTransformerTest extends TestCase
|
||||
{
|
||||
private OrphanVisitDataTransformer $transformer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->transformer = new OrphanVisitDataTransformer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideVisits
|
||||
*/
|
||||
public function visitsAreParsedAsExpected(Visit $visit, array $expectedResult): void
|
||||
{
|
||||
$result = $this->transformer->transform($visit);
|
||||
|
||||
self::assertEquals($expectedResult, $result);
|
||||
}
|
||||
|
||||
public function provideVisits(): iterable
|
||||
{
|
||||
yield 'base path visit' => [
|
||||
$visit = Visit::forBasePath(Visitor::emptyInstance()),
|
||||
[
|
||||
'referer' => '',
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
'userAgent' => '',
|
||||
'visitLocation' => null,
|
||||
'visitedUrl' => '',
|
||||
'type' => Visit::TYPE_BASE_URL,
|
||||
],
|
||||
];
|
||||
yield 'invalid short url visit' => [
|
||||
$visit = Visit::forInvalidShortUrl(Visitor::fromRequest(
|
||||
ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'foo')
|
||||
->withHeader('Referer', 'bar')
|
||||
->withUri(new Uri('https://example.com/foo')),
|
||||
)),
|
||||
[
|
||||
'referer' => 'bar',
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
'userAgent' => 'foo',
|
||||
'visitLocation' => null,
|
||||
'visitedUrl' => 'https://example.com/foo',
|
||||
'type' => Visit::TYPE_INVALID_SHORT_URL,
|
||||
],
|
||||
];
|
||||
yield 'regular 404 visit' => [
|
||||
$visit = Visit::forRegularNotFound(
|
||||
Visitor::fromRequest(
|
||||
ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent')
|
||||
->withHeader('Referer', 'referer')
|
||||
->withUri(new Uri('https://doma.in/foo/bar')),
|
||||
),
|
||||
)->locate($location = new VisitLocation(Location::emptyInstance())),
|
||||
[
|
||||
'referer' => 'referer',
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
'userAgent' => 'user-agent',
|
||||
'visitLocation' => $location,
|
||||
'visitedUrl' => 'https://doma.in/foo/bar',
|
||||
'type' => Visit::TYPE_REGULAR_404,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -57,7 +57,8 @@ class VisitLocatorTest extends TestCase
|
|||
): void {
|
||||
$unlocatedVisits = map(
|
||||
range(1, 200),
|
||||
fn (int $i) => new Visit(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
|
||||
fn (int $i) =>
|
||||
Visit::forValidShortUrl(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
|
||||
);
|
||||
|
||||
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
|
||||
|
@ -107,7 +108,7 @@ class VisitLocatorTest extends TestCase
|
|||
bool $isNonLocatableAddress
|
||||
): void {
|
||||
$unlocatedVisits = [
|
||||
new Visit(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()),
|
||||
Visit::forValidShortUrl(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()),
|
||||
];
|
||||
|
||||
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
|
||||
|
|
|
@ -5,19 +5,35 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
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\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\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
|
||||
|
||||
use function count;
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class VisitsStatsHelperTest extends TestCase
|
||||
{
|
||||
use ApiKeyHelpersTrait;
|
||||
use ProphecyTrait;
|
||||
|
||||
private VisitsStatsHelper $helper;
|
||||
|
@ -36,13 +52,15 @@ class VisitsStatsHelperTest extends TestCase
|
|||
public function returnsExpectedVisitsStats(int $expectedCount): void
|
||||
{
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$count = $repo->countVisits(null)->willReturn($expectedCount);
|
||||
$count = $repo->countVisits(null)->willReturn($expectedCount * 3);
|
||||
$countOrphan = $repo->countOrphanVisits()->willReturn($expectedCount);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$stats = $this->helper->getVisitsStats();
|
||||
|
||||
self::assertEquals(new VisitsStats($expectedCount), $stats);
|
||||
self::assertEquals(new VisitsStats($expectedCount * 3, $expectedCount), $stats);
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
$countOrphan->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
|
@ -50,4 +68,102 @@ class VisitsStatsHelperTest extends TestCase
|
|||
{
|
||||
return map(range(0, 50, 5), fn (int $value) => [$value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAdminApiKeys
|
||||
*/
|
||||
public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$spec = $apiKey === null ? null : $apiKey->spec();
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
|
||||
$list,
|
||||
);
|
||||
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey);
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$count->shouldBeCalledOnce();
|
||||
|
||||
$this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$apiKey = new ApiKey();
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
$tagExists->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAdminApiKeys
|
||||
*/
|
||||
public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$spec = $apiKey === null ? null : $apiKey->spec();
|
||||
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list);
|
||||
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$tagExists->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function orphanVisitsAreReturnedAsExpected(): void
|
||||
{
|
||||
$list = map(range(0, 3), fn () => Visit::forBasePath(Visitor::emptyInstance()));
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$countVisits = $repo->countOrphanVisits(Argument::type(DateRange::class))->willReturn(count($list));
|
||||
$listVisits = $repo->findOrphanVisits(Argument::type(DateRange::class), Argument::cetera())->willReturn($list);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$paginator = $this->helper->orphanVisits(new VisitsParams());
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$listVisits->shouldHaveBeenCalledOnce();
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
81
module/Core/test/Visit/VisitsTrackerTest.php
Normal file
81
module/Core/test/Visit/VisitsTrackerTest.php
Normal file
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsTracker;
|
||||
|
||||
class VisitsTrackerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private VisitsTracker $visitsTracker;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $eventDispatcher;
|
||||
private UrlShortenerOptions $options;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
$this->options = new UrlShortenerOptions();
|
||||
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTrackingMethodNames
|
||||
*/
|
||||
public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void
|
||||
{
|
||||
$this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->{$method}(...$args);
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideTrackingMethodNames(): iterable
|
||||
{
|
||||
yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]];
|
||||
yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]];
|
||||
yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]];
|
||||
yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideOrphanTrackingMethodNames
|
||||
*/
|
||||
public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void
|
||||
{
|
||||
$this->options->trackOrphanVisits = false;
|
||||
|
||||
$this->visitsTracker->{$method}(Visitor::emptyInstance());
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->em->flush()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideOrphanTrackingMethodNames(): iterable
|
||||
{
|
||||
yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit'];
|
||||
yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit'];
|
||||
yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit'];
|
||||
}
|
||||
}
|
|
@ -34,6 +34,7 @@ return [
|
|||
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
|
||||
|
@ -66,9 +67,13 @@ return [
|
|||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrlDataTransformer::class,
|
||||
],
|
||||
Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\Visit\OrphanVisitsAction::class => [
|
||||
Visit\VisitsStatsHelper::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||
],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
|
||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||
|
|
|
@ -34,6 +34,7 @@ return [
|
|||
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\Visit\TagVisitsAction::getRouteDef(),
|
||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||
Action\Visit\OrphanVisitsAction::getRouteDef(),
|
||||
|
||||
// Tags
|
||||
Action\Tag\ListTagsAction::getRouteDef(),
|
||||
|
|
43
module/Rest/src/Action/Visit/OrphanVisitsAction.php
Normal file
43
module/Rest/src/Action/Visit/OrphanVisitsAction.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?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\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class OrphanVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/visits/orphan';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
private DataTransformerInterface $orphanVisitTransformer;
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
DataTransformerInterface $orphanVisitTransformer
|
||||
) {
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
$this->orphanVisitTransformer = $orphanVisitTransformer;
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$visits = $this->visitsHelper->orphanVisits($params);
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits, $this->orphanVisitTransformer),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
|||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
|
@ -21,11 +21,11 @@ class ShortUrlVisitsAction extends AbstractRestAction
|
|||
protected const ROUTE_PATH = '/short-urls/{shortCode}/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
|
@ -33,7 +33,7 @@ class ShortUrlVisitsAction extends AbstractRestAction
|
|||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$visits = $this->visitsTracker->info($identifier, $params, $apiKey);
|
||||
$visits = $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey);
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
|
|
|
@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
|
|||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
|
@ -20,11 +20,11 @@ class TagVisitsAction extends AbstractRestAction
|
|||
protected const ROUTE_PATH = '/tags/{tag}/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
|
@ -32,7 +32,7 @@ class TagVisitsAction extends AbstractRestAction
|
|||
$tag = $request->getAttribute('tag', '');
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey);
|
||||
$visits = $this->visitsHelper->visitsForTag($tag, $params, $apiKey);
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
|
|
|
@ -19,7 +19,9 @@ class GlobalVisitsTest extends ApiTestCase
|
|||
|
||||
self::assertArrayHasKey('visits', $payload);
|
||||
self::assertArrayHasKey('visitsCount', $payload['visits']);
|
||||
self::assertArrayHasKey('orphanVisitsCount', $payload['visits']);
|
||||
self::assertEquals($expectedVisits, $payload['visits']['visitsCount']);
|
||||
self::assertEquals(3, $payload['visits']['orphanVisitsCount']);
|
||||
}
|
||||
|
||||
public function provideApiKeys(): iterable
|
||||
|
|
59
module/Rest/test-api/Action/OrphanVisitsTest.php
Normal file
59
module/Rest/test-api/Action/OrphanVisitsTest.php
Normal file
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
class OrphanVisitsTest extends ApiTestCase
|
||||
{
|
||||
private const INVALID_SHORT_URL = [
|
||||
'referer' => 'https://doma.in/foo',
|
||||
'date' => '2020-03-01T00:00:00+00:00',
|
||||
'userAgent' => 'shlink-tests-agent',
|
||||
'visitLocation' => null,
|
||||
'visitedUrl' => 'foo.com',
|
||||
'type' => 'invalid_short_url',
|
||||
|
||||
];
|
||||
private const REGULAR_NOT_FOUND = [
|
||||
'referer' => 'https://doma.in/foo/bar',
|
||||
'date' => '2020-02-01T00:00:00+00:00',
|
||||
'userAgent' => 'shlink-tests-agent',
|
||||
'visitLocation' => null,
|
||||
'visitedUrl' => '',
|
||||
'type' => 'regular_404',
|
||||
];
|
||||
private const BASE_URL = [
|
||||
'referer' => 'https://doma.in',
|
||||
'date' => '2020-01-01T00:00:00+00:00',
|
||||
'userAgent' => 'shlink-tests-agent',
|
||||
'visitLocation' => null,
|
||||
'visitedUrl' => '',
|
||||
'type' => 'base_url',
|
||||
];
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideQueries
|
||||
*/
|
||||
public function properVisitsAreReturnedBasedInQuery(array $query, int $expectedAmount, array $expectedVisits): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [RequestOptions::QUERY => $query]);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
$visits = $payload['visits']['data'] ?? [];
|
||||
|
||||
self::assertEquals(3, $payload['visits']['pagination']['totalItems'] ?? -1);
|
||||
self::assertCount($expectedAmount, $visits);
|
||||
self::assertEquals($expectedVisits, $visits);
|
||||
}
|
||||
|
||||
public function provideQueries(): iterable
|
||||
{
|
||||
yield 'all data' => [[], 3, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND, self::BASE_URL]];
|
||||
yield 'limit items' => [['itemsPerPage' => 2], 2, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND]];
|
||||
yield 'limit items and page' => [['itemsPerPage' => 2, 'page' => 2], 1, [self::BASE_URL]];
|
||||
}
|
||||
}
|
|
@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Fixtures;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\DataFixtures\AbstractFixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
@ -22,20 +24,54 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
|
|||
{
|
||||
/** @var ShortUrl $abcShortUrl */
|
||||
$abcShortUrl = $this->getReference('abc123_short_url');
|
||||
$manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77')));
|
||||
$manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7')));
|
||||
$manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4')));
|
||||
$manager->persist(
|
||||
Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77', '')),
|
||||
);
|
||||
$manager->persist(Visit::forValidShortUrl(
|
||||
$abcShortUrl,
|
||||
new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7', ''),
|
||||
));
|
||||
$manager->persist(Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', '')));
|
||||
|
||||
/** @var ShortUrl $defShortUrl */
|
||||
$defShortUrl = $this->getReference('def456_short_url');
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1')));
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
|
||||
$manager->persist(
|
||||
Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1', '')),
|
||||
);
|
||||
$manager->persist(
|
||||
Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')),
|
||||
);
|
||||
|
||||
/** @var ShortUrl $ghiShortUrl */
|
||||
$ghiShortUrl = $this->getReference('ghi789_short_url');
|
||||
$manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4')));
|
||||
$manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
|
||||
$manager->persist(Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', '')));
|
||||
$manager->persist(
|
||||
Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')),
|
||||
);
|
||||
|
||||
$manager->persist($this->setVisitDate(
|
||||
Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', '')),
|
||||
'2020-01-01',
|
||||
));
|
||||
$manager->persist($this->setVisitDate(
|
||||
Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://doma.in/foo/bar', '1.2.3.4', '')),
|
||||
'2020-02-01',
|
||||
));
|
||||
$manager->persist($this->setVisitDate(
|
||||
Visit::forInvalidShortUrl(new Visitor('shlink-tests-agent', 'https://doma.in/foo', '1.2.3.4', 'foo.com')),
|
||||
'2020-03-01',
|
||||
));
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
private function setVisitDate(Visit $visit, string $date): Visit
|
||||
{
|
||||
$ref = new ReflectionObject($visit);
|
||||
$dateProp = $ref->getProperty('date');
|
||||
$dateProp->setAccessible(true);
|
||||
$dateProp->setValue($visit, Chronos::parse($date));
|
||||
|
||||
return $visit;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ class GlobalVisitsActionTest extends TestCase
|
|||
public function statsAreReturnedFromHelper(): void
|
||||
{
|
||||
$apiKey = new ApiKey();
|
||||
$stats = new VisitsStats(5);
|
||||
$stats = new VisitsStats(5, 3);
|
||||
$getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats);
|
||||
|
||||
/** @var JsonResponse $resp */
|
||||
|
|
57
module/Rest/test/Action/Visit/OrphanVisitsActionTest.php
Normal file
57
module/Rest/test/Action/Visit/OrphanVisitsActionTest.php
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction;
|
||||
|
||||
use function count;
|
||||
|
||||
class OrphanVisitsActionTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private OrphanVisitsAction $action;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
private ObjectProphecy $orphanVisitTransformer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->orphanVisitTransformer = $this->prophesize(DataTransformerInterface::class);
|
||||
|
||||
$this->action = new OrphanVisitsAction($this->visitsHelper->reveal(), $this->orphanVisitTransformer->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function requestIsHandled(): void
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
$visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)];
|
||||
$orphanVisits = $this->visitsHelper->orphanVisits(Argument::type(VisitsParams::class))->willReturn(
|
||||
new Paginator(new ArrayAdapter($visits)),
|
||||
);
|
||||
$transform = $this->orphanVisitTransformer->transform(Argument::type(Visit::class))->willReturn([]);
|
||||
|
||||
$response = $this->action->handle(ServerRequestFactory::fromGlobals());
|
||||
|
||||
self::assertInstanceOf(JsonResponse::class, $response);
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
$orphanVisits->shouldHaveBeenCalledOnce();
|
||||
$transform->shouldHaveBeenCalledTimes(count($visits));
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
|
|||
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\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
|
@ -25,19 +25,19 @@ class ShortUrlVisitsActionTest extends TestCase
|
|||
use ProphecyTrait;
|
||||
|
||||
private ShortUrlVisitsAction $action;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
|
||||
$this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal());
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->action = new ShortUrlVisitsAction($this->visitsHelper->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingCorrectShortCodeReturnsVisits(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(
|
||||
$this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
Argument::type(VisitsParams::class),
|
||||
Argument::type(ApiKey::class),
|
||||
|
@ -52,7 +52,7 @@ class ShortUrlVisitsActionTest extends TestCase
|
|||
public function paramsAreReadFromQuery(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(
|
||||
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(
|
||||
new DateRange(null, Chronos::parse('2016-01-01 00:00:00')),
|
||||
3,
|
||||
10,
|
||||
|
|
|
@ -12,7 +12,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
|
|||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
|
@ -21,12 +21,12 @@ class TagVisitsActionTest extends TestCase
|
|||
use ProphecyTrait;
|
||||
|
||||
private TagVisitsAction $action;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
|
||||
$this->action = new TagVisitsAction($this->visitsTracker->reveal());
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->action = new TagVisitsAction($this->visitsHelper->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -34,7 +34,7 @@ class TagVisitsActionTest extends TestCase
|
|||
{
|
||||
$tag = 'foo';
|
||||
$apiKey = new ApiKey();
|
||||
$getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn(
|
||||
$getVisits = $this->visitsHelper->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn(
|
||||
new Paginator(new ArrayAdapter([])),
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue