Merge pull request #587 from acelaya-forks/feature/visit-webhook

Feature/visit webhook
This commit is contained in:
Alejandro Celaya 2019-12-29 14:36:40 +01:00 committed by GitHub
commit fd6151040e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 445 additions and 55 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
## 1.21.0 - 2019-12-29
#### Added
@ -22,6 +22,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params.
* The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided.
* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole.
Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit.
The payload will look like this:
```json
{
"shortUrl": {},
"visit": {}
}
```
> The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io).
#### Changed
* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php.

View file

@ -1,10 +1,7 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$container->get(CliApp::class)->run();
$run = require __DIR__ . '/../config/run.php';
$run(true);

View file

@ -36,7 +36,7 @@
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.4",
"shlinkio/shlink-event-dispatcher": "^1.1",
"shlinkio/shlink-installer": "^3.2",
"shlinkio/shlink-installer": "^3.3",
"shlinkio/shlink-ip-geolocation": "^1.2",
"symfony/console": "^5.0",
"symfony/filesystem": "^5.0",
@ -111,7 +111,7 @@
"@test:api"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml --testdox",
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
"test:db": [
"@test:db:sqlite",
"@test:db:mysql",
@ -123,15 +123,15 @@
"@test:db:mysql",
"@test:db:postgres"
],
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --show-mutations",
"infect:ci": "@infect --coverage=build",
"infect:show": "@infect --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"

View file

@ -11,6 +11,8 @@ return [
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
Plugin\UrlShortenerConfigCustomizer::NOTIFY_VISITS_WEBHOOKS,
Plugin\UrlShortenerConfigCustomizer::VISITS_WEBHOOKS,
],
Plugin\ApplicationConfigCustomizer::class => [

View file

@ -10,6 +10,7 @@ return [
'hostname' => '',
],
'validate_url' => true,
'visits_webhooks' => [],
],
];

View file

@ -10,10 +10,15 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
}
// Build container
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['dependencies']);
$container->setService('config', $config);
return $container;
return (function () {
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['dependencies']);
$container->setService('config', $config);
return $container;
})();

15
config/run.php Normal file
View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
use Zend\Expressive\Application;
return function (bool $isCli = false): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
$app = $container->get($isCli ? CliApp::class : Application::class);
$app->run();
};

View file

@ -110,6 +110,7 @@ This is the complete list of supported env vars:
* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`.
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
@ -145,6 +146,7 @@ docker run \
-e "BASE_PATH=/my-campaign" \
-e WEB_WORKER_NUM=64 \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
shlinkio/shlink:stable
```
@ -173,6 +175,10 @@ The whole configuration should have this format, but it can be split into multip
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"
],
"visits_webhooks": [
"http://my-api.com/api/v2.3/notify",
"https://third-party.io/foo"
],
"db_config": {
"driver": "pdo_mysql",
"dbname": "shlink",

View file

@ -99,6 +99,12 @@ $helper = new class {
'base_url' => env('BASE_URL_REDIRECT_TO'),
];
}
public function getVisitsWebhooks(): array
{
$webhooks = env('VISITS_WEBHOOKS');
return $webhooks === null ? [] : explode(',', $webhooks);
}
};
return [
@ -125,6 +131,7 @@ return [
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'validate_url' => (bool) env('VALIDATE_URLS', true),
'visits_webhooks' => $helper->getVisitsWebhooks(),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
@ -11,7 +12,11 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'events' => [
'regular' => [],
'regular' => [
EventDispatcher\VisitLocated::class => [
EventDispatcher\NotifyVisitToWebHooks::class,
],
],
'async' => [
EventDispatcher\ShortUrlVisited::class => [
EventDispatcher\LocateShortUrlVisit::class,
@ -22,6 +27,7 @@ return [
'dependencies' => [
'factories' => [
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
],
],
@ -31,6 +37,15 @@ return [
'em',
'Logger_Shlink',
GeolocationDbUpdater::class,
EventDispatcherInterface::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
'httpClient',
'em',
'Logger_Shlink',
'config.url_shortener.visits_webhooks',
'config.url_shortener.domain',
Options\AppOptions::class,
],
],

View file

@ -32,6 +32,7 @@ class SimplifiedConfigParser
'base_path' => ['router', 'base_path'],
'web_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'worker_num'],
'task_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View file

@ -61,6 +61,11 @@ class Visit extends AbstractEntity implements JsonSerializable
return ! empty($this->remoteAddr);
}
public function getShortUrl(): ShortUrl
{
return $this->shortUrl;
}
public function getVisitLocation(): VisitLocationInterface
{
return $this->visitLocation ?? new UnknownVisitLocation();

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
@ -26,17 +27,21 @@ class LocateShortUrlVisit
private $logger;
/** @var GeolocationDbUpdaterInterface */
private $dbUpdater;
/** @var EventDispatcherInterface */
private $eventDispatcher;
public function __construct(
IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em,
LoggerInterface $logger,
GeolocationDbUpdaterInterface $dbUpdater
GeolocationDbUpdaterInterface $dbUpdater,
EventDispatcherInterface $eventDispatcher
) {
$this->ipLocationResolver = $ipLocationResolver;
$this->em = $em;
$this->logger = $logger;
$this->dbUpdater = $dbUpdater;
$this->eventDispatcher = $eventDispatcher;
}
public function __invoke(ShortUrlVisited $shortUrlVisited): void
@ -46,10 +51,21 @@ class LocateShortUrlVisit
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning(sprintf('Tried to locate visit with id "%s", but it does not exist.', $visitId));
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$this->locateVisit($visitId, $visit);
}
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
}
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
{
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
@ -57,31 +73,32 @@ class LocateShortUrlVisit
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->logger->error(
sprintf(
'GeoLite2 database download failed. It is not possible to locate visit with id %s. {e}',
$visitId
),
['e' => $e]
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
['e' => $e, 'visitId' => $visitId]
);
return;
return false;
}
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
}
return true;
}
private function locateVisit(string $visitId, Visit $visit): void
{
try {
$location = $visit->isLocatable()
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
: Location::emptyInstance();
$visit->locate(new VisitLocation($location));
$this->em->flush();
} catch (WrongIpException $e) {
$this->logger->warning(
sprintf('Tried to locate visit with id "%s", but its address seems to be wrong. {e}', $visitId),
['e' => $e]
'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
['e' => $e, 'visitId' => $visitId]
);
return;
}
$visit->locate(new VisitLocation($location));
$this->em->flush();
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Closure;
use Doctrine\ORM\EntityManagerInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\RequestOptions;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Throwable;
use function Functional\map;
use function Functional\partial_left;
use function GuzzleHttp\Promise\settle;
class NotifyVisitToWebHooks
{
/** @var ClientInterface */
private $httpClient;
/** @var EntityManagerInterface */
private $em;
/** @var LoggerInterface */
private $logger;
/** @var array */
private $webhooks;
/** @var ShortUrlDataTransformer */
private $transformer;
/** @var AppOptions */
private $appOptions;
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
LoggerInterface $logger,
array $webhooks,
array $domainConfig,
AppOptions $appOptions
) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->logger = $logger;
$this->webhooks = $webhooks;
$this->transformer = new ShortUrlDataTransformer($domainConfig);
$this->appOptions = $appOptions;
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
if (empty($this->webhooks)) {
return;
}
$visitId = $shortUrlLocated->visitId();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
$requestOptions = $this->buildRequestOptions($visit);
$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();
}
private function buildRequestOptions(Visit $visit): array
{
return [
RequestOptions::TIMEOUT => 10,
RequestOptions::HEADERS => [
'User-Agent' => (string) $this->appOptions,
],
RequestOptions::JSON => [
'shortUrl' => $this->transformer->transform($visit->getShortUrl(), false),
'visit' => $visit->jsonSerialize(),
],
];
}
/**
* @param Promise[] $requestOptions
*/
private function performRequests(array $requestOptions, string $visitId): array
{
return map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) {
$promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions);
return $promise->otherwise(
partial_left(Closure::fromCallable([$this, 'logWebhookFailure']), $webhook, $visitId)
);
});
}
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
{
$this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [
'visitId' => $visitId,
'webhook' => $webhook,
'e' => $e,
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use JsonSerializable;
final class VisitLocated implements JsonSerializable
{
/** @var string */
private $visitId;
public function __construct(string $visitId)
{
$this->visitId = $visitId;
}
public function visitId(): string
{
return $this->visitId;
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId];
}
}

View file

@ -23,11 +23,11 @@ class ShortUrlDataTransformer implements DataTransformerInterface
/**
* @param ShortUrl $shortUrl
*/
public function transform($shortUrl): array
public function transform($shortUrl, bool $includeDeprecated = true): array
{
$longUrl = $shortUrl->getLongUrl();
return [
$rawData = [
'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => $shortUrl->toString($this->domainConfig),
'longUrl' => $longUrl,
@ -35,10 +35,13 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'visitsCount' => $shortUrl->getVisitsCount(),
'tags' => invoke($shortUrl->getTags(), '__toString'),
'meta' => $this->buildMeta($shortUrl),
// Deprecated
'originalUrl' => $longUrl,
];
if ($includeDeprecated) {
$rawData['originalUrl'] = $longUrl;
}
return $rawData;
}
private function buildMeta(ShortUrl $shortUrl): array

View file

@ -53,6 +53,10 @@ class SimplifiedConfigParserTest extends TestCase
],
'base_path' => '/foo/bar',
'task_worker_num' => 50,
'visits_webhooks' => [
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
];
$expected = [
'app_options' => [
@ -76,6 +80,10 @@ class SimplifiedConfigParserTest extends TestCase
'hostname' => 'doma.in',
],
'validate_url' => false,
'visits_webhooks' => [
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
],
'delete_short_urls' => [

View file

@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
@ -35,6 +37,8 @@ class LocateShortUrlVisitTest extends TestCase
private $logger;
/** @var ObjectProphecy */
private $dbUpdater;
/** @var ObjectProphecy */
private $eventDispatcher;
public function setUp(): void
{
@ -42,12 +46,14 @@ class LocateShortUrlVisitTest extends TestCase
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->locateVisit = new LocateShortUrlVisit(
$this->ipLocationResolver->reveal(),
$this->em->reveal(),
$this->logger->reveal(),
$this->dbUpdater->reveal()
$this->dbUpdater->reveal(),
$this->eventDispatcher->reveal()
);
}
@ -56,7 +62,11 @@ class LocateShortUrlVisitTest extends TestCase
{
$event = new ShortUrlVisited('123');
$findVisit = $this->em->find(Visit::class, '123')->willReturn(null);
$logWarning = $this->logger->warning('Tried to locate visit with id "123", but it does not exist.');
$logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => 123,
]);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -64,6 +74,7 @@ class LocateShortUrlVisitTest extends TestCase
$this->em->flush()->shouldNotHaveBeenCalled();
$this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
$logWarning->shouldHaveBeenCalled();
$dispatch->shouldNotHaveBeenCalled();
}
/** @test */
@ -77,9 +88,11 @@ class LocateShortUrlVisitTest extends TestCase
WrongIpException::class
);
$logWarning = $this->logger->warning(
Argument::containingString('Tried to locate visit with id "123", but its address seems to be wrong.'),
Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'),
Argument::type('array')
);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -87,6 +100,7 @@ class LocateShortUrlVisitTest extends TestCase
$resolveLocation->shouldHaveBeenCalledOnce();
$logWarning->shouldHaveBeenCalled();
$this->em->flush()->shouldNotHaveBeenCalled();
$dispatch->shouldHaveBeenCalledOnce();
}
/**
@ -100,6 +114,8 @@ class LocateShortUrlVisitTest extends TestCase
$flush = $this->em->flush()->will(function () {
});
$resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any());
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -108,6 +124,7 @@ class LocateShortUrlVisitTest extends TestCase
$flush->shouldHaveBeenCalledOnce();
$resolveIp->shouldNotHaveBeenCalled();
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
$dispatch->shouldHaveBeenCalledOnce();
}
public function provideNonLocatableVisits(): iterable
@ -131,6 +148,8 @@ class LocateShortUrlVisitTest extends TestCase
$flush = $this->em->flush()->will(function () {
});
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -139,10 +158,11 @@ class LocateShortUrlVisitTest extends TestCase
$flush->shouldHaveBeenCalledOnce();
$resolveIp->shouldHaveBeenCalledOnce();
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
$dispatch->shouldHaveBeenCalledOnce();
}
/** @test */
public function errorWhenUpdatingGeoliteWithExistingCopyLogsWarning(): void
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
{
$e = GeolocationDbUpdateFailedException::create(true);
$ipAddr = '1.2.3.0';
@ -155,6 +175,8 @@ class LocateShortUrlVisitTest extends TestCase
});
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -167,10 +189,11 @@ class LocateShortUrlVisitTest extends TestCase
'GeoLite2 database update failed. Proceeding with old version. {e}',
['e' => $e]
)->shouldHaveBeenCalledOnce();
$dispatch->shouldHaveBeenCalledOnce();
}
/** @test */
public function errorWhenDownloadingGeoliteCancelsLocation(): void
public function errorWhenDownloadingGeoLiteCancelsLocation(): void
{
$e = GeolocationDbUpdateFailedException::create(false);
$ipAddr = '1.2.3.0';
@ -184,9 +207,11 @@ class LocateShortUrlVisitTest extends TestCase
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
$logError = $this->logger->error(
'GeoLite2 database download failed. It is not possible to locate visit with id 123. {e}',
['e' => $e]
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
['e' => $e, 'visitId' => 123]
);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
});
($this->locateVisit)($event);
@ -196,5 +221,6 @@ class LocateShortUrlVisitTest extends TestCase
$resolveIp->shouldNotHaveBeenCalled();
$checkUpdateDb->shouldHaveBeenCalledOnce();
$logError->shouldHaveBeenCalledOnce();
$dispatch->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use function count;
use function Functional\contains;
class NotifyVisitToWebHooksTest extends TestCase
{
/** @var ObjectProphecy */
private $httpClient;
/** @var ObjectProphecy */
private $em;
/** @var ObjectProphecy */
private $logger;
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class);
}
/** @test */
public function emptyWebhooksMakeNoFurtherActions(): void
{
$find = $this->em->find(Visit::class, '1')->willReturn(null);
$this->createListener([])(new VisitLocated('1'));
$find->shouldNotHaveBeenCalled();
}
/** @test */
public function invalidVisitDoesNotPerformAnyRequest(): void
{
$find = $this->em->find(Visit::class, '1')->willReturn(null);
$requestAsync = $this->httpClient->requestAsync(
RequestMethodInterface::METHOD_POST,
Argument::type('string'),
Argument::type('array')
)->willReturn(new FulfilledPromise(''));
$logWarning = $this->logger->warning(
'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.',
['visitId' => '1']
);
$this->createListener(['foo', 'bar'])(new VisitLocated('1'));
$find->shouldHaveBeenCalledOnce();
$logWarning->shouldHaveBeenCalledOnce();
$requestAsync->shouldNotHaveBeenCalled();
}
/** @test */
public function expectedRequestsArePerformedToWebhooks(): void
{
$webhooks = ['foo', 'invalid', 'bar', 'baz'];
$invalidWebhooks = ['invalid', 'baz'];
$find = $this->em->find(Visit::class, '1')->willReturn(new Visit(new ShortUrl(''), Visitor::emptyInstance()));
$requestAsync = $this->httpClient->requestAsync(
RequestMethodInterface::METHOD_POST,
Argument::type('string'),
Argument::that(function (array $requestOptions) {
Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10);
Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']);
Assert::assertArrayHasKey('shortUrl', $requestOptions[RequestOptions::JSON]);
Assert::assertArrayHasKey('visit', $requestOptions[RequestOptions::JSON]);
return $requestOptions;
})
)->will(function (array $args) use ($invalidWebhooks) {
[, $webhook] = $args;
$e = new Exception('');
return contains($invalidWebhooks, $webhook) ? new RejectedPromise($e) : new FulfilledPromise('');
});
$logWarning = $this->logger->warning(
'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
Argument::that(function (array $extra) {
Assert::assertArrayHasKey('webhook', $extra);
Assert::assertArrayHasKey('visitId', $extra);
Assert::assertArrayHasKey('e', $extra);
return $extra;
})
);
$this->createListener($webhooks)(new VisitLocated('1'));
$find->shouldHaveBeenCalledOnce();
$requestAsync->shouldHaveBeenCalledTimes(count($webhooks));
$logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks));
}
private function createListener(array $webhooks): NotifyVisitToWebHooks
{
return new NotifyVisitToWebHooks(
$this->httpClient->reveal(),
$this->em->reveal(),
$this->logger->reveal(),
$webhooks,
[],
new AppOptions(['name' => 'Shlink', 'version' => '1.2.3'])
);
}
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
bootstrap="./config/test/bootstrap_api_tests.php"
colors="true"
>

View file

@ -1,7 +1,7 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
bootstrap="./config/test/bootstrap_db_tests.php"
colors="true"
>

View file

@ -1,7 +1,7 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
bootstrap="./vendor/autoload.php"
colors="true"
>

View file

@ -2,11 +2,5 @@
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Zend\Expressive\Application;
(function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$container->get(Application::class)->run();
})();
$run = require __DIR__ . '/../config/run.php';
$run();