Merge pull request #417 from acelaya/feature/swoole-tasks

Feature/swoole tasks
This commit is contained in:
Alejandro Celaya 2019-07-20 12:35:43 +02:00 committed by GitHub
commit 0ec7e8c41b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1246 additions and 26 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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*

View file

@ -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

View file

@ -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": {

View file

@ -9,6 +9,11 @@ return [
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
'options' => [
'worker_num' => 16,
'task_worker_num' => 16,
],
],
],

View file

@ -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'

View file

@ -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,
],
],
],

View file

@ -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],

View file

@ -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();
}
}

View file

@ -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()
);
}

View file

@ -20,6 +20,9 @@ abstract class AbstractEntity
return $this->id;
}
/**
* @internal
*/
public function setId(string $id): self
{
$this->id = $id;

View file

@ -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'],

View 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,
],
],
];

View file

@ -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;

View 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);
}
}

View 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];
}
}

View file

@ -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()));
}
/**

View file

@ -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',

View 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();
}
}

View file

@ -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);

View 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,
],
],
];

View 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,
],
],
],
];

View 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);
}

View 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;
}

View 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('');
}
}
}

View 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;
}
}

View 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);
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View file

@ -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);
}
}
}
}

View file

@ -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();
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -2,3 +2,4 @@ parameters:
ignoreErrors:
- '#League\\Plates\\callback#'
- '#is not subtype of Throwable#'
- '#ObjectManager::flush()#'

View file

@ -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>