mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-28 00:38:46 +03:00
Merge pull request #417 from acelaya/feature/swoole-tasks
Feature/swoole tasks
This commit is contained in:
commit
0ec7e8c41b
40 changed files with 1246 additions and 26 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -7,6 +7,7 @@ vendor/
|
|||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
data/GeoLite2-City.mmdb
|
||||
data/GeoLite2-City.mmdb.*
|
||||
docs/swagger-ui*
|
||||
docker-compose.override.yml
|
||||
.phpunit.result.cache
|
||||
|
|
|
@ -27,6 +27,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
}
|
||||
```
|
||||
|
||||
* [#285](https://github.com/shlinkio/shlink/issues/285) Visit location resolution is now done asynchronously but in real time thanks to swoole task management.
|
||||
|
||||
Now, when a short URL is visited, a task is enqueued to locate it. The user is immediately redirected to the long URL, and in the background, the visit is located, making stats to be available a couple of seconds after the visit without the requirement of cronjobs being run constantly.
|
||||
|
||||
Sadly, this feature is not enabled when serving shlink via apache/nginx, where you should still rely on cronjobs.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
|
|
@ -188,7 +188,7 @@ There are a couple of time-consuming tasks that shlink expects you to do manuall
|
|||
|
||||
Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
|
||||
|
||||
* Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
|
||||
* **For shlink older than 1.18.0 or not using swoole as the web server**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
|
||||
|
||||
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
|
||||
|
||||
|
@ -204,7 +204,7 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
|
|||
|
||||
*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
|
||||
|
||||
In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews.
|
||||
> In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews.
|
||||
|
||||
## Update to new version
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"lstrojny/functional-php": "^1.8",
|
||||
"mikehaertl/phpwkhtmltopdf": "^2.2",
|
||||
"monolog/monolog": "^1.21",
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"shlinkio/shlink-installer": "^1.1",
|
||||
"symfony/console": "^4.2",
|
||||
"symfony/filesystem": "^4.2",
|
||||
|
@ -52,6 +53,7 @@
|
|||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"doctrine/data-fixtures": "^1.3",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"filp/whoops": "^2.0",
|
||||
"infection/infection": "^0.12.2",
|
||||
"phpstan/phpstan": "^0.11.2",
|
||||
|
@ -69,10 +71,12 @@
|
|||
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
|
||||
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
|
||||
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
|
||||
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
|
||||
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
|
||||
"Shlinkio\\Shlink\\EventDispatcher\\": "module/EventDispatcher/src"
|
||||
},
|
||||
"files": [
|
||||
"module/Common/functions/functions.php"
|
||||
"module/Common/functions/functions.php",
|
||||
"module/EventDispatcher/functions/functions.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
@ -87,7 +91,8 @@
|
|||
"ShlinkioTest\\Shlink\\Common\\": [
|
||||
"module/Common/test",
|
||||
"module/Common/test-db"
|
||||
]
|
||||
],
|
||||
"ShlinkioTest\\Shlink\\EventDispatcher\\": "module/EventDispatcher/test"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -9,6 +9,11 @@ return [
|
|||
'swoole-http-server' => [
|
||||
'host' => '0.0.0.0',
|
||||
'process-name' => 'shlink',
|
||||
|
||||
'options' => [
|
||||
'worker_num' => 16,
|
||||
'task_worker_num' => 16,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
|||
Core\ConfigProvider::class,
|
||||
CLI\ConfigProvider::class,
|
||||
Rest\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
||||
new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
|
||||
env('APP_ENV') === 'test'
|
||||
|
|
|
@ -70,6 +70,8 @@ return [
|
|||
'process-name' => 'shlink_test',
|
||||
'options' => [
|
||||
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
|
||||
'worker_num' => 1,
|
||||
'task_worker_num' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
@ -48,7 +48,7 @@ return [
|
|||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class],
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, Lock\Factory::class],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
|
||||
|
|
|
@ -9,18 +9,24 @@ use InvalidArgumentException;
|
|||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Common\Exception\RuntimeException;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\Factory as Locker;
|
||||
|
||||
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const LOCK_NAME = 'geolocation-db-update';
|
||||
|
||||
/** @var DbUpdaterInterface */
|
||||
private $dbUpdater;
|
||||
/** @var Reader */
|
||||
private $geoLiteDbReader;
|
||||
/** @var Locker */
|
||||
private $locker;
|
||||
|
||||
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader)
|
||||
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, Locker $locker)
|
||||
{
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
$this->geoLiteDbReader = $geoLiteDbReader;
|
||||
$this->locker = $locker;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -28,6 +34,9 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
|||
*/
|
||||
public function checkDbUpdate(callable $mustBeUpdated = null, callable $handleProgress = null): void
|
||||
{
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(true); // Block until lock is released
|
||||
|
||||
try {
|
||||
$meta = $this->geoLiteDbReader->metadata();
|
||||
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
|
||||
|
@ -36,6 +45,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
|||
} catch (InvalidArgumentException $e) {
|
||||
// This is the exception thrown by the reader when the database file does not exist
|
||||
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,11 +8,13 @@ use GeoIp2\Database\Reader;
|
|||
use InvalidArgumentException;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Exception\RuntimeException;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock;
|
||||
use Throwable;
|
||||
|
||||
use function Functional\map;
|
||||
|
@ -26,15 +28,27 @@ class GeolocationDbUpdaterTest extends TestCase
|
|||
private $dbUpdater;
|
||||
/** @var ObjectProphecy */
|
||||
private $geoLiteDbReader;
|
||||
/** @var ObjectProphecy */
|
||||
private $locker;
|
||||
/** @var ObjectProphecy */
|
||||
private $lock;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
|
||||
$this->geoLiteDbReader = $this->prophesize(Reader::class);
|
||||
|
||||
$this->locker = $this->prophesize(Lock\Factory::class);
|
||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||
$this->lock->acquire(true)->willReturn(true);
|
||||
$this->lock->release()->will(function () {
|
||||
});
|
||||
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
|
||||
|
||||
$this->geolocationDbUpdater = new GeolocationDbUpdater(
|
||||
$this->dbUpdater->reveal(),
|
||||
$this->geoLiteDbReader->reveal()
|
||||
$this->geoLiteDbReader->reveal(),
|
||||
$this->locker->reveal()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,9 @@ abstract class AbstractEntity
|
|||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function setId(string $id): self
|
||||
{
|
||||
$this->id = $id;
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
|
||||
use Zend\Expressive\Router\RouterInterface;
|
||||
|
@ -46,7 +47,7 @@ return [
|
|||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => ['httpClient', 'em', Options\UrlShortenerOptions::class],
|
||||
Service\VisitsTracker::class => ['em'],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\ShortUrlService::class => ['em'],
|
||||
Service\VisitService::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
|
|
36
module/Core/config/event_dispatcher.config.php
Normal file
36
module/Core/config/event_dispatcher.config.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
|
||||
return [
|
||||
|
||||
'events' => [
|
||||
'regular' => [],
|
||||
'async' => [
|
||||
EventDispatcher\ShortUrlVisited::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
GeolocationDbUpdater::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -65,6 +65,11 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
return $this->visitLocation ?? new UnknownVisitLocation();
|
||||
}
|
||||
|
||||
public function isLocatable(): bool
|
||||
{
|
||||
return $this->hasRemoteAddr() && $this->remoteAddr !== IpAddress::LOCALHOST;
|
||||
}
|
||||
|
||||
public function locate(VisitLocation $visitLocation): self
|
||||
{
|
||||
$this->visitLocation = $visitLocation;
|
||||
|
|
86
module/Core/src/EventDispatcher/LocateShortUrlVisit.php
Normal file
86
module/Core/src/EventDispatcher/LocateShortUrlVisit.php
Normal file
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class LocateShortUrlVisit
|
||||
{
|
||||
/** @var IpLocationResolverInterface */
|
||||
private $ipLocationResolver;
|
||||
/** @var EntityManagerInterface */
|
||||
private $em;
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
/** @var GeolocationDbUpdaterInterface */
|
||||
private $dbUpdater;
|
||||
|
||||
public function __construct(
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
) {
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->em = $em;
|
||||
$this->logger = $logger;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||
{
|
||||
$visitId = $shortUrlVisited->visitId();
|
||||
|
||||
/** @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));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
|
||||
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
});
|
||||
} 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]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
|
||||
}
|
||||
|
||||
try {
|
||||
$location = $visit->isLocatable()
|
||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
||||
: Location::emptyInstance();
|
||||
} 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]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$this->em->flush($visit);
|
||||
}
|
||||
}
|
27
module/Core/src/EventDispatcher/ShortUrlVisited.php
Normal file
27
module/Core/src/EventDispatcher/ShortUrlVisited.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class ShortUrlVisited 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];
|
||||
}
|
||||
}
|
|
@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
|
@ -19,10 +21,13 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
{
|
||||
/** @var ORM\EntityManagerInterface */
|
||||
private $em;
|
||||
/** @var EventDispatcherInterface */
|
||||
private $eventDispatcher;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em)
|
||||
public function __construct(ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -41,6 +46,8 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
$em = $this->em;
|
||||
$em->persist($visit);
|
||||
$em->flush($visit);
|
||||
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -25,14 +25,7 @@ final class UnknownVisitLocation implements VisitLocationInterface
|
|||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link https://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return mixed 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 jsonSerialize()
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'countryCode' => 'Unknown',
|
||||
|
|
199
module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php
Normal file
199
module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php
Normal file
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
||||
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||
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\LocateShortUrlVisit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
|
||||
class LocateShortUrlVisitTest extends TestCase
|
||||
{
|
||||
/** @var LocateShortUrlVisit */
|
||||
private $locateVisit;
|
||||
/** @var ObjectProphecy */
|
||||
private $ipLocationResolver;
|
||||
/** @var ObjectProphecy */
|
||||
private $em;
|
||||
/** @var ObjectProphecy */
|
||||
private $logger;
|
||||
/** @var ObjectProphecy */
|
||||
private $dbUpdater;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
|
||||
$this->locateVisit = new LocateShortUrlVisit(
|
||||
$this->ipLocationResolver->reveal(),
|
||||
$this->em->reveal(),
|
||||
$this->logger->reveal(),
|
||||
$this->dbUpdater->reveal()
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function invalidVisitLogsWarning(): void
|
||||
{
|
||||
$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.');
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$this->em->flush(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$logWarning->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function invalidAddressLogsWarning(): void
|
||||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn(
|
||||
new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'))
|
||||
);
|
||||
$resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow(
|
||||
WrongIpException::class
|
||||
);
|
||||
$logWarning = $this->logger->warning(
|
||||
Argument::containingString('Tried to locate visit with id "123", but its address seems to be wrong.'),
|
||||
Argument::type('array')
|
||||
);
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$resolveLocation->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldHaveBeenCalled();
|
||||
$this->em->flush(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideNonLocatableVisits
|
||||
*/
|
||||
public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void
|
||||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush($visit)->will(function () {
|
||||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any());
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance()));
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideNonLocatableVisits(): iterable
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
|
||||
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))];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function locatableVisitsResolveToLocation(): void
|
||||
{
|
||||
$ipAddr = '1.2.3.0';
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush($visit)->will(function () {
|
||||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new VisitLocation($location));
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldHaveBeenCalledOnce();
|
||||
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenUpdatingGeoliteWithExistingCopyLogsWarning(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create(true);
|
||||
$ipAddr = '1.2.3.0';
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush($visit)->will(function () {
|
||||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
|
||||
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new VisitLocation($location));
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldHaveBeenCalledOnce();
|
||||
$checkUpdateDb->shouldHaveBeenCalledOnce();
|
||||
$this->logger->warning(
|
||||
'GeoLite2 database update failed. Proceeding with old version. {e}',
|
||||
['e' => $e]
|
||||
)->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenDownloadingGeoliteCancelsLocation(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create(false);
|
||||
$ipAddr = '1.2.3.0';
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush($visit)->will(function () {
|
||||
});
|
||||
$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]
|
||||
);
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new UnknownVisitLocation());
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldNotHaveBeenCalled();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
$checkUpdateDb->shouldHaveBeenCalledOnce();
|
||||
$logError->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -9,9 +9,11 @@ use PHPUnit\Framework\Assert;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
|
@ -24,15 +26,19 @@ class VisitsTrackerTest extends TestCase
|
|||
private $visitsTracker;
|
||||
/** @var ObjectProphecy */
|
||||
private $em;
|
||||
/** @var EventDispatcherInterface */
|
||||
private $eventDispatcher;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal());
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackPersistsVisit()
|
||||
public function trackPersistsVisit(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
|
@ -40,13 +46,18 @@ class VisitsTrackerTest extends TestCase
|
|||
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
$this->em->persist(Argument::any())->shouldBeCalledOnce();
|
||||
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledOnce();
|
||||
$this->em->flush(Argument::that(function (Visit $visit) {
|
||||
$visit->setId('1');
|
||||
return $visit;
|
||||
}))->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track($shortCode, Visitor::emptyInstance());
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackedIpAddressGetsObfuscated()
|
||||
public function trackedIpAddressGetsObfuscated(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
|
@ -58,13 +69,18 @@ class VisitsTrackerTest extends TestCase
|
|||
$visit = $args[0];
|
||||
Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr());
|
||||
})->shouldBeCalledOnce();
|
||||
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledOnce();
|
||||
$this->em->flush(Argument::that(function (Visit $visit) {
|
||||
$visit->setId('1');
|
||||
return $visit;
|
||||
}))->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track($shortCode, new Visitor('', '', '4.3.2.1'));
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function infoReturnsVisistForCertainShortCode()
|
||||
public function infoReturnsVisitsForCertainShortCode(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
|
|
26
module/EventDispatcher/config/event_dispatcher.config.php
Normal file
26
module/EventDispatcher/config/event_dispatcher.config.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher;
|
||||
|
||||
use Phly\EventDispatcher as Phly;
|
||||
use Psr\EventDispatcher as Psr;
|
||||
|
||||
return [
|
||||
|
||||
'events' => [
|
||||
'regular' => [],
|
||||
'async' => [],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Phly\EventDispatcher::class => Phly\EventDispatcherFactory::class,
|
||||
Psr\ListenerProviderInterface::class => Listener\ListenerProviderFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
Psr\EventDispatcherInterface::class => Phly\EventDispatcher::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
21
module/EventDispatcher/config/task_runner.config.php
Normal file
21
module/EventDispatcher/config/task_runner.config.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher;
|
||||
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Async\TaskRunner::class => Async\TaskRunnerFactory::class,
|
||||
],
|
||||
'delegators' => [
|
||||
HttpServer::class => [
|
||||
Async\TaskRunnerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
11
module/EventDispatcher/functions/functions.php
Normal file
11
module/EventDispatcher/functions/functions.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher;
|
||||
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
function asyncListener(HttpServer $server, string $regularListenerName): Listener\AsyncEventListener
|
||||
{
|
||||
return new Listener\AsyncEventListener($server, $regularListenerName);
|
||||
}
|
13
module/EventDispatcher/src/Async/TaskInterface.php
Normal file
13
module/EventDispatcher/src/Async/TaskInterface.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
interface TaskInterface
|
||||
{
|
||||
public function run(ContainerInterface $container): void;
|
||||
|
||||
public function toString(): string;
|
||||
}
|
54
module/EventDispatcher/src/Async/TaskRunner.php
Normal file
54
module/EventDispatcher/src/Async/TaskRunner.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
use Throwable;
|
||||
|
||||
use function get_class;
|
||||
use function gettype;
|
||||
use function is_object;
|
||||
|
||||
class TaskRunner
|
||||
{
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
/** @var ContainerInterface */
|
||||
private $container;
|
||||
|
||||
public function __construct(LoggerInterface $logger, ContainerInterface $container)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
public function __invoke(HttpServer $server, int $taskId, int $fromId, $task): void
|
||||
{
|
||||
if (! $task instanceof TaskInterface) {
|
||||
$this->logger->warning('Invalid task provided to task worker: {type}. Task ignored', [
|
||||
'type' => is_object($task) ? get_class($task) : gettype($task),
|
||||
]);
|
||||
$server->finish('');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->notice('Starting work on task {taskId}: {task}', [
|
||||
'taskId' => $taskId,
|
||||
'task' => $task->toString(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$task->run($this->container);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Error processing task {taskId}: {e}', [
|
||||
'taskId' => $taskId,
|
||||
'e' => $e,
|
||||
]);
|
||||
} finally {
|
||||
$server->finish('');
|
||||
}
|
||||
}
|
||||
}
|
29
module/EventDispatcher/src/Async/TaskRunnerDelegator.php
Normal file
29
module/EventDispatcher/src/Async/TaskRunnerDelegator.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
use Zend\ServiceManager\Factory\DelegatorFactoryInterface;
|
||||
|
||||
class TaskRunnerDelegator implements DelegatorFactoryInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ContainerInterface $container,
|
||||
$name,
|
||||
callable $callback,
|
||||
array $options = null
|
||||
): HttpServer {
|
||||
$server = $callback();
|
||||
$logger = $container->get(LoggerInterface::class);
|
||||
|
||||
$server->on('task', $container->get(TaskRunner::class));
|
||||
$server->on('finish', function (HttpServer $server, int $taskId) use ($logger) {
|
||||
$logger->notice('Task #{taskId} has finished processing', ['taskId' => $taskId]);
|
||||
});
|
||||
|
||||
return $server;
|
||||
}
|
||||
}
|
17
module/EventDispatcher/src/Async/TaskRunnerFactory.php
Normal file
17
module/EventDispatcher/src/Async/TaskRunnerFactory.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
|
||||
class TaskRunnerFactory implements FactoryInterface
|
||||
{
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): TaskRunner
|
||||
{
|
||||
$logger = $container->get(LoggerInterface::class);
|
||||
return new TaskRunner($logger, $container);
|
||||
}
|
||||
}
|
15
module/EventDispatcher/src/ConfigProvider.php
Normal file
15
module/EventDispatcher/src/ConfigProvider.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher;
|
||||
|
||||
use Zend\Config\Factory;
|
||||
use Zend\Stdlib\Glob;
|
||||
|
||||
class ConfigProvider
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
|
||||
}
|
||||
}
|
25
module/EventDispatcher/src/Listener/AsyncEventListener.php
Normal file
25
module/EventDispatcher/src/Listener/AsyncEventListener.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
class AsyncEventListener
|
||||
{
|
||||
/** @var string */
|
||||
private $regularListenerName;
|
||||
/** @var HttpServer */
|
||||
private $server;
|
||||
|
||||
public function __construct(HttpServer $server, string $regularListenerName)
|
||||
{
|
||||
$this->regularListenerName = $regularListenerName;
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
public function __invoke(object $event): void
|
||||
{
|
||||
$this->server->task(new EventListenerTask($this->regularListenerName, $event));
|
||||
}
|
||||
}
|
34
module/EventDispatcher/src/Listener/EventListenerTask.php
Normal file
34
module/EventDispatcher/src/Listener/EventListenerTask.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskInterface;
|
||||
|
||||
use function get_class;
|
||||
use function sprintf;
|
||||
|
||||
class EventListenerTask implements TaskInterface
|
||||
{
|
||||
/** @var string */
|
||||
private $listenerName;
|
||||
/** @var object */
|
||||
private $event;
|
||||
|
||||
public function __construct(string $listenerName, object $event)
|
||||
{
|
||||
$this->listenerName = $listenerName;
|
||||
$this->event = $event;
|
||||
}
|
||||
|
||||
public function run(ContainerInterface $container): void
|
||||
{
|
||||
($container->get($this->listenerName))($this->event);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return sprintf('Listener -> "%s", Event -> "%s"', $this->listenerName, get_class($this->event));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
|
||||
use function Phly\EventDispatcher\lazyListener;
|
||||
use function Shlinkio\Shlink\EventDispatcher\asyncListener;
|
||||
|
||||
class ListenerProviderFactory implements FactoryInterface
|
||||
{
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
{
|
||||
$config = $container->has('config') ? $container->get('config') : [];
|
||||
$events = $config['events'] ?? [];
|
||||
$provider = new AttachableListenerProvider();
|
||||
|
||||
$this->registerListeners($events['regular'] ?? [], $container, $provider);
|
||||
$this->registerListeners($events['async'] ?? [], $container, $provider, true);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function registerListeners(
|
||||
array $events,
|
||||
ContainerInterface $container,
|
||||
AttachableListenerProvider $provider,
|
||||
bool $isAsync = false
|
||||
): void {
|
||||
if (empty($events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid registering async event listeners when the swoole server is not registered
|
||||
if ($isAsync && ! $container->has(HttpServer::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($events as $eventName => $listeners) {
|
||||
foreach ($listeners as $listenerName) {
|
||||
$eventListener = $isAsync
|
||||
? asyncListener($container->get(HttpServer::class), $listenerName)
|
||||
: lazyListener($container, $listenerName);
|
||||
|
||||
$provider->listen($eventName, $eventListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskRunner;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskRunnerDelegator;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
class TaskRunnerDelegatorTest extends TestCase
|
||||
{
|
||||
/** @var TaskRunnerDelegator */
|
||||
private $delegator;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->delegator = new TaskRunnerDelegator();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function serverIsFetchedFromCallbackAndDecorated(): void
|
||||
{
|
||||
$server = $this->createMock(HttpServer::class);
|
||||
$server
|
||||
->expects($this->exactly(2))
|
||||
->method('on');
|
||||
$callback = function () use ($server) {
|
||||
return $server;
|
||||
};
|
||||
|
||||
$container = $this->prophesize(ContainerInterface::class);
|
||||
$getTaskRunner = $container->get(TaskRunner::class)->willReturn($this->prophesize(TaskRunner::class)->reveal());
|
||||
$getLogger = $container->get(LoggerInterface::class)->willReturn(
|
||||
$this->prophesize(LoggerInterface::class)->reveal()
|
||||
);
|
||||
|
||||
$result = ($this->delegator)($container->reveal(), '', $callback);
|
||||
|
||||
$this->assertSame($server, $result);
|
||||
$getTaskRunner->shouldHaveBeenCalledOnce();
|
||||
$getLogger->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
48
module/EventDispatcher/test/Async/TaskRunnerFactoryTest.php
Normal file
48
module/EventDispatcher/test/Async/TaskRunnerFactoryTest.php
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskRunner;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskRunnerFactory;
|
||||
|
||||
class TaskRunnerFactoryTest extends TestCase
|
||||
{
|
||||
/** @var TaskRunnerFactory */
|
||||
private $factory;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->factory = new TaskRunnerFactory();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properlyCreatesService(): void
|
||||
{
|
||||
$loggerMock = $this->prophesize(LoggerInterface::class);
|
||||
$logger = $loggerMock->reveal();
|
||||
$containerMock = $this->prophesize(ContainerInterface::class);
|
||||
$getLogger = $containerMock->get(LoggerInterface::class)->willReturn($logger);
|
||||
$container = $containerMock->reveal();
|
||||
|
||||
$taskRunner = ($this->factory)($container, '');
|
||||
$loggerProp = $this->getPropertyFromTaskRunner($taskRunner, 'logger');
|
||||
$containerProp = $this->getPropertyFromTaskRunner($taskRunner, 'container');
|
||||
|
||||
$this->assertSame($container, $containerProp);
|
||||
$this->assertSame($logger, $loggerProp);
|
||||
$getLogger->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function getPropertyFromTaskRunner(TaskRunner $taskRunner, string $propertyName)
|
||||
{
|
||||
$ref = new ReflectionObject($taskRunner);
|
||||
$prop = $ref->getProperty($propertyName);
|
||||
$prop->setAccessible(true);
|
||||
return $prop->getValue($taskRunner);
|
||||
}
|
||||
}
|
107
module/EventDispatcher/test/Async/TaskRunnerTest.php
Normal file
107
module/EventDispatcher/test/Async/TaskRunnerTest.php
Normal file
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Async;
|
||||
|
||||
use Exception;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskInterface;
|
||||
use Shlinkio\Shlink\EventDispatcher\Async\TaskRunner;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
class TaskRunnerTest extends TestCase
|
||||
{
|
||||
/** @var TaskRunner */
|
||||
private $taskRunner;
|
||||
/** @var ObjectProphecy */
|
||||
private $logger;
|
||||
/** @var ObjectProphecy */
|
||||
private $container;
|
||||
/** @var HttpServer */
|
||||
private $server;
|
||||
/** @var ObjectProphecy */
|
||||
private $task;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
$this->container = $this->prophesize(ContainerInterface::class);
|
||||
$this->task = $this->prophesize(TaskInterface::class);
|
||||
|
||||
$this->server = $this->createMock(HttpServer::class);
|
||||
$this->server
|
||||
->expects($this->once())
|
||||
->method('finish')
|
||||
->with('');
|
||||
|
||||
$this->taskRunner = new TaskRunner($this->logger->reveal(), $this->container->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function warningIsLoggedWhenProvidedTaskIsInvalid(): void
|
||||
{
|
||||
$logWarning = $this->logger->warning('Invalid task provided to task worker: {type}. Task ignored', [
|
||||
'type' => 'string',
|
||||
]);
|
||||
$logInfo = $this->logger->info(Argument::cetera());
|
||||
$logError = $this->logger->error(Argument::cetera());
|
||||
|
||||
($this->taskRunner)($this->server, 1, 1, 'invalid_task');
|
||||
|
||||
$logWarning->shouldHaveBeenCalledOnce();
|
||||
$logInfo->shouldNotHaveBeenCalled();
|
||||
$logError->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properTasksAreRun(): void
|
||||
{
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logInfo = $this->logger->notice('Starting work on task {taskId}: {task}', [
|
||||
'taskId' => 1,
|
||||
'task' => 'The task',
|
||||
]);
|
||||
$logError = $this->logger->error(Argument::cetera());
|
||||
$taskToString = $this->task->toString()->willReturn('The task');
|
||||
$taskRun = $this->task->run($this->container->reveal())->will(function () {
|
||||
});
|
||||
|
||||
($this->taskRunner)($this->server, 1, 1, $this->task->reveal());
|
||||
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logInfo->shouldHaveBeenCalledOnce();
|
||||
$logError->shouldNotHaveBeenCalled();
|
||||
$taskToString->shouldHaveBeenCalledOnce();
|
||||
$taskRun->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorIsLoggedWhenTasksFail(): void
|
||||
{
|
||||
$e = new Exception('Error');
|
||||
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logInfo = $this->logger->notice('Starting work on task {taskId}: {task}', [
|
||||
'taskId' => 1,
|
||||
'task' => 'The task',
|
||||
]);
|
||||
$logError = $this->logger->error('Error processing task {taskId}: {e}', [
|
||||
'taskId' => 1,
|
||||
'e' => $e,
|
||||
]);
|
||||
$taskToString = $this->task->toString()->willReturn('The task');
|
||||
$taskRun = $this->task->run($this->container->reveal())->willThrow($e);
|
||||
|
||||
($this->taskRunner)($this->server, 1, 1, $this->task->reveal());
|
||||
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logInfo->shouldHaveBeenCalledOnce();
|
||||
$logError->shouldHaveBeenCalledOnce();
|
||||
$taskToString->shouldHaveBeenCalledOnce();
|
||||
$taskRun->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
27
module/EventDispatcher/test/ConfigProviderTest.php
Normal file
27
module/EventDispatcher/test/ConfigProviderTest.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\EventDispatcher\ConfigProvider;
|
||||
|
||||
class ConfigProviderTest extends TestCase
|
||||
{
|
||||
/** @var ConfigProvider */
|
||||
private $configProvider;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->configProvider = new ConfigProvider();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function configIsReturned(): void
|
||||
{
|
||||
$config = $this->configProvider->__invoke();
|
||||
|
||||
$this->assertArrayHasKey('dependencies', $config);
|
||||
$this->assertArrayHasKey('events', $config);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\EventDispatcher\Listener\AsyncEventListener;
|
||||
use Shlinkio\Shlink\EventDispatcher\Listener\EventListenerTask;
|
||||
use stdClass;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
class AsyncEventListenerTest extends TestCase
|
||||
{
|
||||
/** @var AsyncEventListener */
|
||||
private $eventListener;
|
||||
/** @var HttpServer */
|
||||
private $server;
|
||||
/** @var string */
|
||||
private $regularListenerName;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->regularListenerName = 'the_regular_listener';
|
||||
$this->server = $this->createMock(HttpServer::class);
|
||||
|
||||
$this->eventListener = new AsyncEventListener($this->server, $this->regularListenerName);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enqueuesTaskWhenInvoked(): void
|
||||
{
|
||||
$event = new stdClass();
|
||||
|
||||
$this->server
|
||||
->expects($this->once())
|
||||
->method('task')
|
||||
->with(new EventListenerTask($this->regularListenerName, $event));
|
||||
|
||||
($this->eventListener)($event);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\EventDispatcher\Listener\EventListenerTask;
|
||||
use stdClass;
|
||||
|
||||
use function get_class;
|
||||
use function sprintf;
|
||||
|
||||
class EventListenerTaskTest extends TestCase
|
||||
{
|
||||
/** @var EventListenerTask */
|
||||
private $task;
|
||||
/** @var object */
|
||||
private $event;
|
||||
/** @var string */
|
||||
private $listenerName;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->event = new stdClass();
|
||||
$this->listenerName = 'the_listener';
|
||||
|
||||
$this->task = new EventListenerTask($this->listenerName, $this->event);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function toStringReturnsTheStringRepresentation(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
sprintf('Listener -> "%s", Event -> "%s"', $this->listenerName, get_class($this->event)),
|
||||
$this->task->toString()
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function runInvokesContainerAndListenerWithEvent(): void
|
||||
{
|
||||
$invoked = false;
|
||||
$container = $this->prophesize(ContainerInterface::class);
|
||||
$listener = function (object $event) use (&$invoked) {
|
||||
$invoked = true;
|
||||
Assert::assertSame($event, $this->event);
|
||||
};
|
||||
|
||||
$getListener = $container->get($this->listenerName)->willReturn($listener);
|
||||
|
||||
$this->task->run($container->reveal());
|
||||
|
||||
$this->assertTrue($invoked);
|
||||
$getListener->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\EventDispatcher\Listener;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\EventDispatcher\Listener\ListenerProviderFactory;
|
||||
use Swoole\Http\Server as HttpServer;
|
||||
|
||||
use function Phly\EventDispatcher\lazyListener;
|
||||
use function Shlinkio\Shlink\EventDispatcher\asyncListener;
|
||||
|
||||
class ListenerProviderFactoryTest extends TestCase
|
||||
{
|
||||
/** @var ListenerProviderFactory */
|
||||
private $factory;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->factory = new ListenerProviderFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideContainersWithoutEvents
|
||||
*/
|
||||
public function noListenersAreAttachedWhenNoConfigOrEventsAreRegistered(ContainerInterface $container): void
|
||||
{
|
||||
$provider = ($this->factory)($container, '');
|
||||
$listeners = $this->getListenersFromProvider($provider);
|
||||
|
||||
$this->assertInstanceOf(AttachableListenerProvider::class, $provider);
|
||||
$this->assertEmpty($listeners);
|
||||
}
|
||||
|
||||
public function provideContainersWithoutEvents(): iterable
|
||||
{
|
||||
yield 'no config' => [(function () {
|
||||
$container = $this->prophesize(ContainerInterface::class);
|
||||
$container->has('config')->willReturn(false);
|
||||
|
||||
return $container->reveal();
|
||||
})()];
|
||||
yield 'no events' => [(function () {
|
||||
$container = $this->prophesize(ContainerInterface::class);
|
||||
$container->has('config')->willReturn(true);
|
||||
$container->get('config')->willReturn([]);
|
||||
|
||||
return $container->reveal();
|
||||
})()];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function configuredRegularEventsAreProperlyAttached(): void
|
||||
{
|
||||
$containerMock = $this->prophesize(ContainerInterface::class);
|
||||
$containerMock->has('config')->willReturn(true);
|
||||
$containerMock->get('config')->willReturn([
|
||||
'events' => [
|
||||
'regular' => [
|
||||
'foo' => [
|
||||
'bar',
|
||||
'baz',
|
||||
],
|
||||
'something' => [
|
||||
'some_listener',
|
||||
'another_listener',
|
||||
'foobar',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$container = $containerMock->reveal();
|
||||
|
||||
$provider = ($this->factory)($container, '');
|
||||
$listeners = $this->getListenersFromProvider($provider);
|
||||
|
||||
$this->assertInstanceOf(AttachableListenerProvider::class, $provider);
|
||||
$this->assertEquals([
|
||||
'foo' => [
|
||||
lazyListener($container, 'bar'),
|
||||
lazyListener($container, 'baz'),
|
||||
],
|
||||
'something' => [
|
||||
lazyListener($container, 'some_listener'),
|
||||
lazyListener($container, 'another_listener'),
|
||||
lazyListener($container, 'foobar'),
|
||||
],
|
||||
], $listeners);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function configuredAsyncEventsAreProperlyAttached(): void
|
||||
{
|
||||
$server = $this->createMock(HttpServer::class); // Some weird errors are thrown if prophesize is used
|
||||
|
||||
$containerMock = $this->prophesize(ContainerInterface::class);
|
||||
$containerMock->has('config')->willReturn(true);
|
||||
$containerMock->get('config')->willReturn([
|
||||
'events' => [
|
||||
'async' => [
|
||||
'foo' => [
|
||||
'bar',
|
||||
'baz',
|
||||
],
|
||||
'something' => [
|
||||
'some_listener',
|
||||
'another_listener',
|
||||
'foobar',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$containerMock->has(HttpServer::class)->willReturn(true);
|
||||
$containerMock->get(HttpServer::class)->willReturn($server);
|
||||
$container = $containerMock->reveal();
|
||||
|
||||
$provider = ($this->factory)($container, '');
|
||||
$listeners = $this->getListenersFromProvider($provider);
|
||||
|
||||
$this->assertInstanceOf(AttachableListenerProvider::class, $provider);
|
||||
$this->assertEquals([
|
||||
'foo' => [
|
||||
asyncListener($server, 'bar'),
|
||||
asyncListener($server, 'baz'),
|
||||
],
|
||||
'something' => [
|
||||
asyncListener($server, 'some_listener'),
|
||||
asyncListener($server, 'another_listener'),
|
||||
asyncListener($server, 'foobar'),
|
||||
],
|
||||
], $listeners);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function ignoresAsyncEventsWhenServerIsNotRegistered(): void
|
||||
{
|
||||
$containerMock = $this->prophesize(ContainerInterface::class);
|
||||
$containerMock->has('config')->willReturn(true);
|
||||
$containerMock->get('config')->willReturn([
|
||||
'events' => [
|
||||
'async' => [
|
||||
'foo' => [
|
||||
'bar',
|
||||
'baz',
|
||||
],
|
||||
'something' => [
|
||||
'some_listener',
|
||||
'another_listener',
|
||||
'foobar',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$containerMock->has(HttpServer::class)->willReturn(false);
|
||||
$container = $containerMock->reveal();
|
||||
|
||||
$provider = ($this->factory)($container, '');
|
||||
$listeners = $this->getListenersFromProvider($provider);
|
||||
|
||||
$this->assertInstanceOf(AttachableListenerProvider::class, $provider);
|
||||
$this->assertEmpty($listeners);
|
||||
}
|
||||
|
||||
private function getListenersFromProvider($provider): array
|
||||
{
|
||||
$ref = new ReflectionObject($provider);
|
||||
$prop = $ref->getProperty('listeners');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
return $prop->getValue($provider);
|
||||
}
|
||||
}
|
|
@ -2,3 +2,4 @@ parameters:
|
|||
ignoreErrors:
|
||||
- '#League\\Plates\\callback#'
|
||||
- '#is not subtype of Throwable#'
|
||||
- '#ObjectManager::flush()#'
|
||||
|
|
|
@ -18,8 +18,8 @@
|
|||
<testsuite name="CLI">
|
||||
<directory>./module/CLI/test</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Installer">
|
||||
<directory>./module/Installer/test</directory>
|
||||
<testsuite name="EventDispatcher">
|
||||
<directory>./module/EventDispatcher/test</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
|
|
Loading…
Reference in a new issue