diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist
index d6de56d7..afbc52ed 100644
--- a/config/autoload/errorhandler.local.php.dist
+++ b/config/autoload/errorhandler.local.php.dist
@@ -1,6 +1,5 @@
InvokableFactory::class,
// View
- ContentBasedErrorHandler::class => AnnotatedFactory::class,
- ErrorHandlerManager::class => ErrorHandlerManagerFactory::class,
Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class,
],
'aliases' => [
diff --git a/module/CLI/src/Command/GenerateShortcodeCommand.php b/module/CLI/src/Command/GenerateShortcodeCommand.php
index 0a0af9eb..f02c110b 100644
--- a/module/CLI/src/Command/GenerateShortcodeCommand.php
+++ b/module/CLI/src/Command/GenerateShortcodeCommand.php
@@ -52,7 +52,7 @@ class GenerateShortcodeCommand extends Command
{
$this->setName('shortcode:generate')
->setDescription(
- $this->translator->translate('Generates a shortcode for provided URL and returns the short URL')
+ $this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'));
}
@@ -87,8 +87,8 @@ class GenerateShortcodeCommand extends Command
return;
}
- $shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
- $shortUrl = (new Uri())->withPath($shortcode)
+ $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
+ $shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
diff --git a/module/CLI/src/Factory/ApplicationFactory.php b/module/CLI/src/Factory/ApplicationFactory.php
index 51d5fdb3..a8e24bf3 100644
--- a/module/CLI/src/Factory/ApplicationFactory.php
+++ b/module/CLI/src/Factory/ApplicationFactory.php
@@ -25,7 +25,7 @@ class ApplicationFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['cli'];
- $app = new CliApp();
+ $app = new CliApp('Shlink', '1.0.0');
$commands = isset($config['commands']) ? $config['commands'] : [];
foreach ($commands as $command) {
diff --git a/module/CLI/test/Command/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/GenerateShortcodeCommandTest.php
new file mode 100644
index 00000000..45cb8130
--- /dev/null
+++ b/module/CLI/test/Command/GenerateShortcodeCommandTest.php
@@ -0,0 +1,70 @@
+urlShortener = $this->prophesize(UrlShortener::class);
+ $command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [
+ 'schema' => 'http',
+ 'hostname' => 'foo.com'
+ ]);
+ $app = new Application();
+ $app->add($command);
+ $this->commandTester = new CommandTester($command);
+ }
+
+ /**
+ * @test
+ */
+ public function properShortCodeIsCreatedIfLongUrlIsCorrect()
+ {
+ $this->urlShortener->urlToShortCode(Argument::any())->willReturn('abc123')
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:generate',
+ 'longUrl' => 'http://domain.com/foo/bar'
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertTrue(strpos($output, 'http://foo.com/abc123') > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function exceptionWhileParsingLongUrlOutputsError()
+ {
+ $this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException())
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:generate',
+ 'longUrl' => 'http://domain.com/invalid'
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertTrue(
+ strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0
+ );
+ }
+}
diff --git a/module/CLI/test/Command/GetVisitsCommandTest.php b/module/CLI/test/Command/GetVisitsCommandTest.php
new file mode 100644
index 00000000..4294823b
--- /dev/null
+++ b/module/CLI/test/Command/GetVisitsCommandTest.php
@@ -0,0 +1,91 @@
+visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
+ $command = new GetVisitsCommand($this->visitsTracker->reveal(), Translator::factory([]));
+ $app = new Application();
+ $app->add($command);
+ $this->commandTester = new CommandTester($command);
+ }
+
+ /**
+ * @test
+ */
+ public function noDateFlagsTriesToListWithoutDateRange()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:visits',
+ 'shortCode' => $shortCode,
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function providingDateFlagsTheListGetsFiltered()
+ {
+ $shortCode = 'abc123';
+ $startDate = '2016-01-01';
+ $endDate = '2016-02-01';
+ $this->visitsTracker->info($shortCode, new DateRange(new \DateTime($startDate), new \DateTime($endDate)))
+ ->willReturn([])
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:visits',
+ 'shortCode' => $shortCode,
+ '--startDate' => $startDate,
+ '--endDate' => $endDate,
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function outputIsProperlyGenerated()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, Argument::any())->willReturn([
+ (new Visit())->setReferer('foo')
+ ->setRemoteAddr('1.2.3.4')
+ ->setUserAgent('bar'),
+ ])->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:visits',
+ 'shortCode' => $shortCode,
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertTrue(strpos($output, 'foo') > 0);
+ $this->assertTrue(strpos($output, '1.2.3.4') > 0);
+ $this->assertTrue(strpos($output, 'bar') > 0);
+ }
+}
diff --git a/module/CLI/test/Command/ProcessVisitsCommandTest.php b/module/CLI/test/Command/ProcessVisitsCommandTest.php
new file mode 100644
index 00000000..23463123
--- /dev/null
+++ b/module/CLI/test/Command/ProcessVisitsCommandTest.php
@@ -0,0 +1,96 @@
+visitService = $this->prophesize(VisitService::class);
+ $this->ipResolver = $this->prophesize(IpLocationResolver::class);
+ $command = new ProcessVisitsCommand(
+ $this->visitService->reveal(),
+ $this->ipResolver->reveal(),
+ Translator::factory([])
+ );
+ $app = new Application();
+ $app->add($command);
+
+ $this->commandTester = new CommandTester($command);
+ }
+
+ /**
+ * @test
+ */
+ public function allReturnedVisitsIpsAreProcessed()
+ {
+ $visits = [
+ (new Visit())->setRemoteAddr('1.2.3.4'),
+ (new Visit())->setRemoteAddr('4.3.2.1'),
+ (new Visit())->setRemoteAddr('12.34.56.78'),
+ ];
+ $this->visitService->getUnlocatedVisits()->willReturn($visits)
+ ->shouldBeCalledTimes(1);
+
+ $this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits));
+ $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
+ ->shouldBeCalledTimes(count($visits));
+
+ $this->commandTester->execute([
+ 'command' => 'visit:process',
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0);
+ $this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0);
+ $this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function localhostAddressIsIgnored()
+ {
+ $visits = [
+ (new Visit())->setRemoteAddr('1.2.3.4'),
+ (new Visit())->setRemoteAddr('4.3.2.1'),
+ (new Visit())->setRemoteAddr('12.34.56.78'),
+ (new Visit())->setRemoteAddr('127.0.0.1'),
+ (new Visit())->setRemoteAddr('127.0.0.1'),
+ ];
+ $this->visitService->getUnlocatedVisits()->willReturn($visits)
+ ->shouldBeCalledTimes(1);
+
+ $this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
+ $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
+ ->shouldBeCalledTimes(count($visits) - 2);
+
+ $this->commandTester->execute([
+ 'command' => 'visit:process',
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
+ }
+}
diff --git a/module/CLI/test/Command/ResolveUrlCommandTest.php b/module/CLI/test/Command/ResolveUrlCommandTest.php
new file mode 100644
index 00000000..a2f47c92
--- /dev/null
+++ b/module/CLI/test/Command/ResolveUrlCommandTest.php
@@ -0,0 +1,85 @@
+urlShortener = $this->prophesize(UrlShortener::class);
+ $command = new ResolveUrlCommand($this->urlShortener->reveal(), Translator::factory([]));
+ $app = new Application();
+ $app->add($command);
+
+ $this->commandTester = new CommandTester($command);
+ }
+
+ /**
+ * @test
+ */
+ public function correctShortCodeResolvesUrl()
+ {
+ $shortCode = 'abc123';
+ $expectedUrl = 'http://domain.com/foo/bar';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:parse',
+ 'shortCode' => $shortCode,
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
+ }
+
+ /**
+ * @test
+ */
+ public function incorrectShortCodeOutputsErrorMessage()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:parse',
+ 'shortCode' => $shortCode,
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertEquals('No URL found for short code "' . $shortCode . '"' . PHP_EOL, $output);
+ }
+
+ /**
+ * @test
+ */
+ public function wrongShortCodeFormatOutputsErrorMessage()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
+ ->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:parse',
+ 'shortCode' => $shortCode,
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertEquals('Provided short code "' . $shortCode . '" has an invalid format.' . PHP_EOL, $output);
+ }
+}
diff --git a/module/Common/config/error-handler.config.php b/module/Common/config/error-handler.config.php
index a6165fa2..d19b9ac6 100644
--- a/module/Common/config/error-handler.config.php
+++ b/module/Common/config/error-handler.config.php
@@ -1,5 +1,5 @@
TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
+
+ ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class,
+ ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
diff --git a/module/Common/src/Expressive/ContentBasedErrorHandler.php b/module/Common/src/ErrorHandler/ContentBasedErrorHandler.php
similarity index 73%
rename from module/Common/src/Expressive/ContentBasedErrorHandler.php
rename to module/Common/src/ErrorHandler/ContentBasedErrorHandler.php
index df6cde91..be19e848 100644
--- a/module/Common/src/Expressive/ContentBasedErrorHandler.php
+++ b/module/Common/src/ErrorHandler/ContentBasedErrorHandler.php
@@ -1,5 +1,5 @@
hasHeader('Accept') ? $request->getHeaderLine('Accept') : self::DEFAULT_CONTENT;
$accepts = explode(',', $accepts);
foreach ($accepts as $accept) {
@@ -59,8 +60,17 @@ class ContentBasedErrorHandler implements ErrorHandlerInterface
return $this->errorHandlerManager->get($accept);
}
+ // If it wasn't possible to find an error handler for accepted content type, use default one if registered
+ if ($this->errorHandlerManager->has(self::DEFAULT_CONTENT)) {
+ return $this->errorHandlerManager->get(self::DEFAULT_CONTENT);
+ }
+
+ // It wasn't possible to find an error handler
throw new InvalidArgumentException(sprintf(
- 'It wasn\'t possible to find an error handler for '
+ 'It wasn\'t possible to find an error handler for ["%s"] content types. '
+ . 'Make sure you have registered at least the default "%s" content type',
+ implode('", "', $accepts),
+ self::DEFAULT_CONTENT
));
}
}
diff --git a/module/Common/src/Expressive/ErrorHandlerInterface.php b/module/Common/src/ErrorHandler/ErrorHandlerInterface.php
similarity index 89%
rename from module/Common/src/Expressive/ErrorHandlerInterface.php
rename to module/Common/src/ErrorHandler/ErrorHandlerInterface.php
index 9f0b5803..9676c40a 100644
--- a/module/Common/src/Expressive/ErrorHandlerInterface.php
+++ b/module/Common/src/ErrorHandler/ErrorHandlerInterface.php
@@ -1,5 +1,5 @@
configProvider = new ConfigProvider();
+ }
+
+ /**
+ * @test
+ */
+ public function configIsReturned()
+ {
+ $config = $this->configProvider->__invoke();
+
+ $this->assertArrayHasKey('error_handler', $config);
+ $this->assertArrayHasKey('middleware_pipeline', $config);
+ $this->assertArrayHasKey('services', $config);
+ $this->assertArrayHasKey('twig', $config);
+ }
+}
diff --git a/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php b/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php
new file mode 100644
index 00000000..6b480e54
--- /dev/null
+++ b/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php
@@ -0,0 +1,75 @@
+errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [
+ 'factories' => [
+ 'text/html' => [$this, 'factory'],
+ 'application/json' => [$this, 'factory'],
+ ],
+ ]));
+ }
+
+ public function factory($container, $name)
+ {
+ return function () use ($name) {
+ return $name;
+ };
+ }
+
+ /**
+ * @test
+ */
+ public function correctAcceptHeaderValueInvokesErrorHandler()
+ {
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,application/json');
+ $result = $this->errorHandler->__invoke($request, new Response());
+ $this->assertEquals('application/json', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function defaultContentTypeIsUsedWhenNoAcceptHeaderisPresent()
+ {
+ $request = ServerRequestFactory::fromGlobals();
+ $result = $this->errorHandler->__invoke($request, new Response());
+ $this->assertEquals('text/html', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function defaultContentTypeIsUsedWhenAcceptedContentIsNotSupported()
+ {
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml');
+ $result = $this->errorHandler->__invoke($request, new Response());
+ $this->assertEquals('text/html', $result);
+ }
+
+ /**
+ * @test
+ * @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
+ */
+ public function ifNoErrorHandlerIsFoundAnExceptionIsThrown()
+ {
+ $this->errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), []));
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml');
+ $result = $this->errorHandler->__invoke($request, new Response());
+ }
+}
diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php
new file mode 100644
index 00000000..be6d4e6d
--- /dev/null
+++ b/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php
@@ -0,0 +1,35 @@
+factory = new ErrorHandlerManagerFactory();
+ }
+
+ /**
+ * @test
+ */
+ public function serviceIsCreated()
+ {
+ $instance = $this->factory->__invoke(new ServiceManager(['services' => [
+ 'config' => [
+ 'error_handler' => [
+ 'plugins' => [],
+ ],
+ ],
+ ]]), '');
+ $this->assertInstanceOf(ErrorHandlerManager::class, $instance);
+ }
+}
diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php
new file mode 100644
index 00000000..4b14f113
--- /dev/null
+++ b/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php
@@ -0,0 +1,45 @@
+pluginManager = new ErrorHandlerManager(new ServiceManager(), [
+ 'services' => [
+ 'foo' => function () {
+ },
+ ],
+ 'invokables' => [
+ 'invalid' => \stdClass::class,
+ ]
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function callablesAreReturned()
+ {
+ $instance = $this->pluginManager->get('foo');
+ $this->assertInstanceOf(\Closure::class, $instance);
+ }
+
+ /**
+ * @test
+ * @expectedException \Zend\ServiceManager\Exception\InvalidServiceException
+ */
+ public function nonCallablesThrowException()
+ {
+ $this->pluginManager->get('invalid');
+ }
+}
diff --git a/module/Common/test/Factory/TranslatorFactoryTest.php b/module/Common/test/Factory/TranslatorFactoryTest.php
new file mode 100644
index 00000000..e70f820b
--- /dev/null
+++ b/module/Common/test/Factory/TranslatorFactoryTest.php
@@ -0,0 +1,31 @@
+factory = new TranslatorFactory();
+ }
+
+ /**
+ * @test
+ */
+ public function serviceIsCreated()
+ {
+ $instance = $this->factory->__invoke(new ServiceManager(['services' => [
+ 'config' => [],
+ ]]), '');
+ $this->assertInstanceOf(Translator::class, $instance);
+ }
+}
diff --git a/module/Common/test/Middleware/LocaleMiddlewareTest.php b/module/Common/test/Middleware/LocaleMiddlewareTest.php
new file mode 100644
index 00000000..72e6bf77
--- /dev/null
+++ b/module/Common/test/Middleware/LocaleMiddlewareTest.php
@@ -0,0 +1,71 @@
+translator = Translator::factory(['locale' => 'ru']);
+ $this->middleware = new LocaleMiddleware($this->translator);
+ }
+
+ /**
+ * @test
+ */
+ public function whenNoHeaderIsPresentLocaleIsNotChanged()
+ {
+ $this->assertEquals('ru', $this->translator->getLocale());
+ $this->middleware->__invoke(ServerRequestFactory::fromGlobals(), new Response(), function ($req, $resp) {
+ return $resp;
+ });
+ $this->assertEquals('ru', $this->translator->getLocale());
+ }
+
+ /**
+ * @test
+ */
+ public function whenTheHeaderIsPresentLocaleIsChanged()
+ {
+ $this->assertEquals('ru', $this->translator->getLocale());
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es');
+ $this->middleware->__invoke($request, new Response(), function ($req, $resp) {
+ return $resp;
+ });
+ $this->assertEquals('es', $this->translator->getLocale());
+ }
+
+ /**
+ * @test
+ */
+ public function localeGetsNormalized()
+ {
+ $this->assertEquals('ru', $this->translator->getLocale());
+
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es_ES');
+ $this->middleware->__invoke($request, new Response(), function ($req, $resp) {
+ return $resp;
+ });
+ $this->assertEquals('es', $this->translator->getLocale());
+
+ $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'en-US');
+ $this->middleware->__invoke($request, new Response(), function ($req, $resp) {
+ return $resp;
+ });
+ $this->assertEquals('en', $this->translator->getLocale());
+ }
+}
diff --git a/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php
new file mode 100644
index 00000000..1135682f
--- /dev/null
+++ b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php
@@ -0,0 +1,43 @@
+repo = $this->prophesize(PaginableRepositoryInterface::class);
+ $this->adapter = new PaginableRepositoryAdapter($this->repo->reveal(), 'search', 'order');
+ }
+
+ /**
+ * @test
+ */
+ public function getItemsFallbacksToFindList()
+ {
+ $this->repo->findList(10, 5, 'search', 'order')->shouldBeCalledTimes(1);
+ $this->adapter->getItems(5, 10);
+ }
+
+ /**
+ * @test
+ */
+ public function countFallbacksToCountList()
+ {
+ $this->repo->countList('search')->shouldBeCalledTimes(1);
+ $this->adapter->count();
+ }
+}
diff --git a/module/Common/test/Service/IpLocationResolverTest.php b/module/Common/test/Service/IpLocationResolverTest.php
new file mode 100644
index 00000000..e6130569
--- /dev/null
+++ b/module/Common/test/Service/IpLocationResolverTest.php
@@ -0,0 +1,56 @@
+client = $this->prophesize(Client::class);
+ $this->ipResolver = new IpLocationResolver($this->client->reveal());
+ }
+
+ /**
+ * @test
+ */
+ public function correctIpReturnsDecodedInfo()
+ {
+ $expected = [
+ 'foo' => 'bar',
+ 'baz' => 'foo',
+ ];
+ $response = new Response();
+ $response->getBody()->write(json_encode($expected));
+ $response->getBody()->rewind();
+
+ $this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response)
+ ->shouldBeCalledTimes(1);
+ $this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
+ }
+
+ /**
+ * @test
+ * @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
+ */
+ public function guzzleExceptionThrowsShlinkException()
+ {
+ $this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException())
+ ->shouldBeCalledTimes(1);
+ $this->ipResolver->resolveIpLocation('1.2.3.4');
+ }
+}
diff --git a/module/Common/test/Twig/Extension/TranslatorExtensionTest.php b/module/Common/test/Twig/Extension/TranslatorExtensionTest.php
new file mode 100644
index 00000000..06b5a584
--- /dev/null
+++ b/module/Common/test/Twig/Extension/TranslatorExtensionTest.php
@@ -0,0 +1,70 @@
+translator = $this->prophesize(Translator::class);
+ $this->extension = new TranslatorExtension($this->translator->reveal());
+ }
+
+ /**
+ * @test
+ */
+ public function extensionNameIsClassName()
+ {
+ $this->assertEquals(TranslatorExtension::class, $this->extension->getName());
+ }
+
+ /**
+ * @test
+ */
+ public function properFunctionsAreReturned()
+ {
+ $funcs = $this->extension->getFunctions();
+ $this->assertCount(2, $funcs);
+ foreach ($funcs as $func) {
+ $this->assertInstanceOf(\Twig_SimpleFunction::class, $func);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function translateFallbacksToTranslator()
+ {
+ $this->translator->translate('foo', 'default', null)->shouldBeCalledTimes(1);
+ $this->extension->translate('foo');
+
+ $this->translator->translate('bar', 'baz', 'en')->shouldBeCalledTimes(1);
+ $this->extension->translate('bar', 'baz', 'en');
+ }
+
+ /**
+ * @test
+ */
+ public function translatePluralFallbacksToTranslator()
+ {
+ $this->translator->translatePlural('foo', 'bar', 'baz', 'default', null)->shouldBeCalledTimes(1);
+ $this->extension->translatePlural('foo', 'bar', 'baz');
+
+ $this->translator->translatePlural('foo', 'bar', 'baz', 'another', 'en')->shouldBeCalledTimes(1);
+ $this->extension->translatePlural('foo', 'bar', 'baz', 'another', 'en');
+ }
+}
diff --git a/module/Common/test/Util/DateRangeTest.php b/module/Common/test/Util/DateRangeTest.php
new file mode 100644
index 00000000..4ed1586e
--- /dev/null
+++ b/module/Common/test/Util/DateRangeTest.php
@@ -0,0 +1,32 @@
+assertNull($range->getStartDate());
+ $this->assertNull($range->getEndDate());
+ $this->assertTrue($range->isEmpty());
+ }
+
+ /**
+ * @test
+ */
+ public function providedDatesAreSet()
+ {
+ $startDate = new \DateTime();
+ $endDate = new \DateTime();
+ $range = new DateRange($startDate, $endDate);
+ $this->assertSame($startDate, $range->getStartDate());
+ $this->assertSame($endDate, $range->getEndDate());
+ $this->assertFalse($range->isEmpty());
+ }
+}
diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php
index 122f7f29..031aa0fe 100644
--- a/module/Core/src/Action/RedirectAction.php
+++ b/module/Core/src/Action/RedirectAction.php
@@ -70,10 +70,10 @@ class RedirectAction implements MiddlewareInterface
// If provided shortCode does not belong to a valid long URL, dispatch next middleware, which will trigger
// a not-found error
if (! isset($longUrl)) {
- return $out($request, $response->withStatus(404), 'Not found');
+ return $this->notFoundResponse($request, $response, $out);
}
- // Track visit to this shortcode
+ // Track visit to this short code
$this->visitTracker->track($shortCode);
// Return a redirect response to the long URL.
@@ -81,7 +81,18 @@ class RedirectAction implements MiddlewareInterface
return new RedirectResponse($longUrl);
} catch (\Exception $e) {
// In case of error, dispatch 404 error
- return $out($request, $response);
+ return $this->notFoundResponse($request, $response, $out);
}
}
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param callable $out
+ * @return Response
+ */
+ protected function notFoundResponse(Request $request, Response $response, callable $out)
+ {
+ return $out($request, $response->withStatus(404), 'Not Found');
+ }
}
diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php
new file mode 100644
index 00000000..434189fc
--- /dev/null
+++ b/module/Core/test/Action/RedirectActionTest.php
@@ -0,0 +1,104 @@
+urlShortener = $this->prophesize(UrlShortener::class);
+ $visitTracker = $this->prophesize(VisitsTracker::class);
+ $visitTracker->track(Argument::any());
+ $this->action = new RedirectAction($this->urlShortener->reveal(), $visitTracker->reveal());
+ }
+
+ /**
+ * @test
+ */
+ public function redirectionIsPerformedToLongUrl()
+ {
+ $shortCode = 'abc123';
+ $expectedUrl = 'http://domain.com/foo/bar';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $response = $this->action->__invoke($request, new Response());
+
+ $this->assertInstanceOf(Response\RedirectResponse::class, $response);
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertTrue($response->hasHeader('Location'));
+ $this->assertEquals($expectedUrl, $response->getHeaderLine('Location'));
+ }
+
+ /**
+ * @test
+ */
+ public function nextErrorMiddlewareIsInvokedIfLongUrlIsNotFound()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $originalResponse = new Response();
+ $test = $this;
+ $this->action->__invoke($request, $originalResponse, function (
+ ServerRequestInterface $req,
+ ResponseInterface $resp,
+ $error
+ ) use (
+ $test,
+ $request
+ ) {
+ $test->assertSame($request, $req);
+ $test->assertEquals(404, $resp->getStatusCode());
+ $test->assertEquals('Not Found', $error);
+ });
+ }
+
+ /**
+ * @test
+ */
+ public function nextErrorMiddlewareIsInvokedIfAnExceptionIsThrown()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(\Exception::class)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $originalResponse = new Response();
+ $test = $this;
+ $this->action->__invoke($request, $originalResponse, function (
+ ServerRequestInterface $req,
+ ResponseInterface $resp,
+ $error
+ ) use (
+ $test,
+ $request
+ ) {
+ $test->assertSame($request, $req);
+ $test->assertEquals(404, $resp->getStatusCode());
+ $test->assertEquals('Not Found', $error);
+ });
+ }
+}
diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php
new file mode 100644
index 00000000..fdd41cad
--- /dev/null
+++ b/module/Core/test/ConfigProviderTest.php
@@ -0,0 +1,32 @@
+configProvider = new ConfigProvider();
+ }
+
+ /**
+ * @test
+ */
+ public function properConfigIsReturned()
+ {
+ $config = $this->configProvider->__invoke();
+
+ $this->assertArrayHasKey('routes', $config);
+ $this->assertArrayHasKey('services', $config);
+ $this->assertArrayHasKey('templates', $config);
+ $this->assertArrayHasKey('translator', $config);
+ $this->assertArrayHasKey('zend-expressive', $config);
+ }
+}
diff --git a/module/Core/test/Service/VisitServiceTest.php b/module/Core/test/Service/VisitServiceTest.php
new file mode 100644
index 00000000..2ecf0a45
--- /dev/null
+++ b/module/Core/test/Service/VisitServiceTest.php
@@ -0,0 +1,49 @@
+em = $this->prophesize(EntityManager::class);
+ $this->visitService = new VisitService($this->em->reveal());
+ }
+
+ /**
+ * @test
+ */
+ public function saveVisitsPersistsProvidedVisit()
+ {
+ $visit = new Visit();
+ $this->em->persist($visit)->shouldBeCalledTimes(1);
+ $this->em->flush()->shouldBeCalledTimes(1);
+ $this->visitService->saveVisit($visit);
+ }
+
+ /**
+ * @test
+ */
+ public function getUnlocatedVisitsFallbacksToRepository()
+ {
+ $repo = $this->prophesize(VisitRepository::class);
+ $repo->findUnlocatedVisits()->shouldBeCalledTimes(1);
+ $this->em->getRepository(Visit::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
+ $this->visitService->getUnlocatedVisits();
+ }
+}
diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php
index 2a441e04..4d8a68fe 100644
--- a/module/Core/test/Service/VisitsTrackerTest.php
+++ b/module/Core/test/Service/VisitsTrackerTest.php
@@ -5,11 +5,29 @@ use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
+use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
+use Shlinkio\Shlink\Core\Entity\Visit;
+use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
class VisitsTrackerTest extends TestCase
{
+ /**
+ * @var VisitsTracker
+ */
+ protected $visitsTracker;
+ /**
+ * @var ObjectProphecy
+ */
+ protected $em;
+
+ public function setUp()
+ {
+ $this->em = $this->prophesize(EntityManager::class);
+ $this->visitsTracker = new VisitsTracker($this->em->reveal());
+ }
+
/**
* @test
*/
@@ -19,12 +37,32 @@ class VisitsTrackerTest extends TestCase
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl());
- $em = $this->prophesize(EntityManager::class);
- $em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
- $em->persist(Argument::any())->shouldBeCalledTimes(1);
- $em->flush()->shouldBeCalledTimes(1);
+ $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
+ $this->em->persist(Argument::any())->shouldBeCalledTimes(1);
+ $this->em->flush()->shouldBeCalledTimes(1);
- $visitsTracker = new VisitsTracker($em->reveal());
- $visitsTracker->track($shortCode);
+ $this->visitsTracker->track($shortCode);
+ }
+
+ /**
+ * @test
+ */
+ public function infoReturnsVisistForCertainShortCode()
+ {
+ $shortCode = '123ABC';
+ $shortUrl = (new ShortUrl())->setOriginalUrl('http://domain.com/foo/bar');
+ $repo = $this->prophesize(EntityRepository::class);
+ $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl);
+ $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
+
+ $list = [
+ new Visit(),
+ new Visit(),
+ ];
+ $repo2 = $this->prophesize(VisitRepository::class);
+ $repo2->findVisitsByShortUrl($shortUrl, null)->willReturn($list);
+ $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledTimes(1);
+
+ $this->assertEquals($list, $this->visitsTracker->info($shortCode));
}
}
diff --git a/module/Rest/config/error-handler.config.php b/module/Rest/config/error-handler.config.php
index 4b5b7e75..fef71303 100644
--- a/module/Rest/config/error-handler.config.php
+++ b/module/Rest/config/error-handler.config.php
@@ -1,5 +1,5 @@
tokenService = $this->prophesize(RestTokenService::class);
+ $this->action = new AuthenticateAction($this->tokenService->reveal(), Translator::factory([]));
+ }
+
+ /**
+ * @test
+ */
+ public function notProvidingAuthDataReturnsError()
+ {
+ $resp = $this->action->__invoke(ServerRequestFactory::fromGlobals(), new Response());
+ $this->assertEquals(400, $resp->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function properCredentialsReturnTokenInResponse()
+ {
+ $this->tokenService->createToken('foo', 'bar')->willReturn(
+ (new RestToken())->setToken('abc-ABC')
+ )->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withParsedBody([
+ 'username' => 'foo',
+ 'password' => 'bar',
+ ]);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $response->getBody()->rewind();
+ $this->assertEquals(['token' => 'abc-ABC'], json_decode($response->getBody()->getContents(), true));
+ }
+
+ /**
+ * @test
+ */
+ public function authenticationExceptionsReturnErrorResponse()
+ {
+ $this->tokenService->createToken('foo', 'bar')->willThrow(new AuthenticationException())
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withParsedBody([
+ 'username' => 'foo',
+ 'password' => 'bar',
+ ]);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(401, $response->getStatusCode());
+ }
+}
diff --git a/module/Rest/test/Action/CreateShortcodeActionTest.php b/module/Rest/test/Action/CreateShortcodeActionTest.php
new file mode 100644
index 00000000..dcc56bf6
--- /dev/null
+++ b/module/Rest/test/Action/CreateShortcodeActionTest.php
@@ -0,0 +1,92 @@
+urlShortener = $this->prophesize(UrlShortener::class);
+ $this->action = new CreateShortcodeAction($this->urlShortener->reveal(), Translator::factory([]), [
+ 'schema' => 'http',
+ 'hostname' => 'foo.com',
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function missingLongUrlParamReturnsError()
+ {
+ $response = $this->action->__invoke(ServerRequestFactory::fromGlobals(), new Response());
+ $this->assertEquals(400, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function properShortcodeConversionReturnsData()
+ {
+ $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willReturn('abc123')
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withParsedBody([
+ 'longUrl' => 'http://www.domain.com/foo/bar',
+ ]);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), 'http://foo.com/abc123') > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function anInvalidUrlReturnsError()
+ {
+ $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(InvalidUrlException::class)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withParsedBody([
+ 'longUrl' => 'http://www.domain.com/foo/bar',
+ ]);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_URL_ERROR) > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function aGenericExceptionWillReturnError()
+ {
+ $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(\Exception::class)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withParsedBody([
+ 'longUrl' => 'http://www.domain.com/foo/bar',
+ ]);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0);
+ }
+}
diff --git a/module/Rest/test/Action/GetVisitsActionTest.php b/module/Rest/test/Action/GetVisitsActionTest.php
new file mode 100644
index 00000000..901c549e
--- /dev/null
+++ b/module/Rest/test/Action/GetVisitsActionTest.php
@@ -0,0 +1,99 @@
+visitsTracker = $this->prophesize(VisitsTracker::class);
+ $this->action = new GetVisitsAction($this->visitsTracker->reveal(), Translator::factory([]));
+ }
+
+ /**
+ * @test
+ */
+ public function providingCorrectShortCodeReturnsVisits()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willReturn([])
+ ->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response()
+ );
+ $this->assertEquals(200, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function providingInvalidShortCodeReturnsError()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
+ InvalidArgumentException::class
+ )->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response()
+ );
+ $this->assertEquals(400, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function unexpectedExceptionWillReturnError()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
+ \Exception::class
+ )->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response()
+ );
+ $this->assertEquals(500, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function datesAreReadFromQuery()
+ {
+ $shortCode = 'abc123';
+ $this->visitsTracker->info($shortCode, new DateRange(null, new \DateTime('2016-01-01 00:00:00')))
+ ->willReturn([])
+ ->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode)
+ ->withQueryParams(['endDate' => '2016-01-01 00:00:00']),
+ new Response()
+ );
+ $this->assertEquals(200, $response->getStatusCode());
+ }
+}
diff --git a/module/Rest/test/Action/ListShortcodesActionTest.php b/module/Rest/test/Action/ListShortcodesActionTest.php
new file mode 100644
index 00000000..b5ec0c9d
--- /dev/null
+++ b/module/Rest/test/Action/ListShortcodesActionTest.php
@@ -0,0 +1,66 @@
+service = $this->prophesize(ShortUrlService::class);
+ $this->action = new ListShortcodesAction($this->service->reveal(), Translator::factory([]));
+ }
+
+ /**
+ * @test
+ */
+ public function properListReturnsSuccessResponse()
+ {
+ $page = 3;
+ $this->service->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter()))
+ ->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withQueryParams([
+ 'page' => $page,
+ ]),
+ new Response()
+ );
+ $this->assertEquals(200, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function anExceptionsReturnsErrorResponse()
+ {
+ $page = 3;
+ $this->service->listShortUrls($page)->willThrow(\Exception::class)
+ ->shouldBeCalledTimes(1);
+
+ $response = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withQueryParams([
+ 'page' => $page,
+ ]),
+ new Response()
+ );
+ $this->assertEquals(500, $response->getStatusCode());
+ }
+}
diff --git a/module/Rest/test/Action/ResolveUrlActionTest.php b/module/Rest/test/Action/ResolveUrlActionTest.php
new file mode 100644
index 00000000..0bd3bded
--- /dev/null
+++ b/module/Rest/test/Action/ResolveUrlActionTest.php
@@ -0,0 +1,90 @@
+urlShortener = $this->prophesize(UrlShortener::class);
+ $this->action = new ResolveUrlAction($this->urlShortener->reveal(), Translator::factory([]));
+ }
+
+ /**
+ * @test
+ */
+ public function incorrectShortCodeReturnsError()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_ARGUMENT_ERROR) > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function correctShortCodeReturnsSuccess()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn('http://domain.com/foo/bar')
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function invalidShortCodeExceptionReturnsError()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(400, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_SHORTCODE_ERROR) > 0);
+ }
+
+ /**
+ * @test
+ */
+ public function unexpectedExceptionWillReturnError()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(\Exception::class)
+ ->shouldBeCalledTimes(1);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
+ $response = $this->action->__invoke($request, new Response());
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0);
+ }
+}
diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php
new file mode 100644
index 00000000..377c845f
--- /dev/null
+++ b/module/Rest/test/ConfigProviderTest.php
@@ -0,0 +1,33 @@
+configProvider = new ConfigProvider();
+ }
+
+ /**
+ * @test
+ */
+ public function properConfigIsReturned()
+ {
+ $config = $this->configProvider->__invoke();
+
+ $this->assertArrayHasKey('error_handler', $config);
+ $this->assertArrayHasKey('middleware_pipeline', $config);
+ $this->assertArrayHasKey('rest', $config);
+ $this->assertArrayHasKey('routes', $config);
+ $this->assertArrayHasKey('services', $config);
+ $this->assertArrayHasKey('translator', $config);
+ }
+}
diff --git a/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php b/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php
new file mode 100644
index 00000000..ea1eee80
--- /dev/null
+++ b/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php
@@ -0,0 +1,79 @@
+errorHandler = new JsonErrorHandler();
+ }
+
+ /**
+ * @test
+ */
+ public function noMatchedRouteReturnsNotFoundResponse()
+ {
+ $response = $this->errorHandler->__invoke(ServerRequestFactory::fromGlobals(), new Response());
+ $this->assertInstanceOf(Response\JsonResponse::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function matchedRouteWithErrorReturnsMethodNotAllowedResponse()
+ {
+ $response = $this->errorHandler->__invoke(
+ ServerRequestFactory::fromGlobals(),
+ (new Response())->withStatus(405),
+ 405
+ );
+ $this->assertInstanceOf(Response\JsonResponse::class, $response);
+ $this->assertEquals(405, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function responseWithErrorKeepsStatus()
+ {
+ $response = $this->errorHandler->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('foo', 'bar', [])
+ ),
+ (new Response())->withStatus(401),
+ 401
+ );
+ $this->assertInstanceOf(Response\JsonResponse::class, $response);
+ $this->assertEquals(401, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function responseWithoutErrorReturnsStatus500()
+ {
+ $response = $this->errorHandler->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('foo', 'bar', [])
+ ),
+ (new Response())->withStatus(200),
+ 'Some error'
+ );
+ $this->assertInstanceOf(Response\JsonResponse::class, $response);
+ $this->assertEquals(500, $response->getStatusCode());
+ }
+}
diff --git a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php
new file mode 100644
index 00000000..650d4d2f
--- /dev/null
+++ b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php
@@ -0,0 +1,134 @@
+tokenService = $this->prophesize(RestTokenService::class);
+ $this->middleware = new CheckAuthenticationMiddleware($this->tokenService->reveal(), Translator::factory([]));
+ }
+
+ /**
+ * @test
+ */
+ public function someWhitelistedSituationsFallbackToNextMiddleware()
+ {
+ $request = ServerRequestFactory::fromGlobals();
+ $response = new Response();
+ $isCalled = false;
+ $this->assertFalse($isCalled);
+ $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) {
+ $isCalled = true;
+ });
+ $this->assertTrue($isCalled);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteFailure(['GET'])
+ );
+ $response = new Response();
+ $isCalled = false;
+ $this->assertFalse($isCalled);
+ $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) {
+ $isCalled = true;
+ });
+ $this->assertTrue($isCalled);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('rest-authenticate', 'foo', [])
+ );
+ $response = new Response();
+ $isCalled = false;
+ $this->assertFalse($isCalled);
+ $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) {
+ $isCalled = true;
+ });
+ $this->assertTrue($isCalled);
+
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('bar', 'foo', [])
+ )->withMethod('OPTIONS');
+ $response = new Response();
+ $isCalled = false;
+ $this->assertFalse($isCalled);
+ $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) {
+ $isCalled = true;
+ });
+ $this->assertTrue($isCalled);
+ }
+
+ /**
+ * @test
+ */
+ public function noHeaderReturnsError()
+ {
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('bar', 'foo', [])
+ );
+ $response = $this->middleware->__invoke($request, new Response());
+ $this->assertEquals(401, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function provideAnExpiredTokenReturnsError()
+ {
+ $authToken = 'ABC-abc';
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('bar', 'foo', [])
+ )->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
+ $this->tokenService->getByToken($authToken)->willReturn(
+ (new RestToken())->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D')))
+ )->shouldBeCalledTimes(1);
+
+ $response = $this->middleware->__invoke($request, new Response());
+ $this->assertEquals(401, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function provideCorrectTokenUpdatesExpirationAndFallbacksToNextMiddleware()
+ {
+ $authToken = 'ABC-abc';
+ $restToken = (new RestToken())->setExpirationDate((new \DateTime())->add(new \DateInterval('P1D')));
+ $request = ServerRequestFactory::fromGlobals()->withAttribute(
+ RouteResult::class,
+ RouteResult::fromRouteMatch('bar', 'foo', [])
+ )->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
+ $this->tokenService->getByToken($authToken)->willReturn($restToken)->shouldBeCalledTimes(1);
+ $this->tokenService->updateExpiration($restToken)->shouldBeCalledTimes(1);
+
+ $isCalled = false;
+ $this->assertFalse($isCalled);
+ $this->middleware->__invoke($request, new Response(), function ($req, $resp) use (&$isCalled) {
+ $isCalled = true;
+ });
+ $this->assertTrue($isCalled);
+ }
+}
diff --git a/module/Rest/test/Service/RestTokenServiceTest.php b/module/Rest/test/Service/RestTokenServiceTest.php
new file mode 100644
index 00000000..d4487ff1
--- /dev/null
+++ b/module/Rest/test/Service/RestTokenServiceTest.php
@@ -0,0 +1,93 @@
+em = $this->prophesize(EntityManager::class);
+ $this->service = new RestTokenService($this->em->reveal(), [
+ 'username' => 'foo',
+ 'password' => 'bar',
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function tokenIsCreatedIfCredentialsAreCorrect()
+ {
+ $this->em->persist(Argument::type(RestToken::class))->shouldBeCalledTimes(1);
+ $this->em->flush()->shouldBeCalledTimes(1);
+
+ $token = $this->service->createToken('foo', 'bar');
+ $this->assertInstanceOf(RestToken::class, $token);
+ $this->assertFalse($token->isExpired());
+ }
+
+ /**
+ * @test
+ * @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException
+ */
+ public function exceptionIsThrownWhileCreatingTokenWithWrongCredentials()
+ {
+ $this->service->createToken('foo', 'wrong');
+ }
+
+ /**
+ * @test
+ */
+ public function restTokenIsReturnedFromTokenString()
+ {
+ $authToken = 'ABC-abc';
+ $theToken = new RestToken();
+ $repo = $this->prophesize(EntityRepository::class);
+ $repo->findOneBy(['token' => $authToken])->willReturn($theToken)->shouldBeCalledTimes(1);
+ $this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
+
+ $this->assertSame($theToken, $this->service->getByToken($authToken));
+ }
+
+ /**
+ * @test
+ * @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
+ */
+ public function exceptionIsThrownWhenRequestingWrongToken()
+ {
+ $authToken = 'ABC-abc';
+ $repo = $this->prophesize(EntityRepository::class);
+ $repo->findOneBy(['token' => $authToken])->willReturn(null)->shouldBeCalledTimes(1);
+ $this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
+
+ $this->service->getByToken($authToken);
+ }
+
+ /**
+ * @test
+ */
+ public function updateExpirationFlushesEntityManager()
+ {
+ $token = $this->prophesize(RestToken::class);
+ $token->updateExpiration()->shouldBeCalledTimes(1);
+ $this->em->flush()->shouldBeCalledTimes(1);
+
+ $this->service->updateExpiration($token->reveal());
+ }
+}
diff --git a/module/Rest/test/Util/RestUtilsTest.php b/module/Rest/test/Util/RestUtilsTest.php
new file mode 100644
index 00000000..d53b6905
--- /dev/null
+++ b/module/Rest/test/Util/RestUtilsTest.php
@@ -0,0 +1,40 @@
+assertEquals(
+ RestUtils::INVALID_SHORTCODE_ERROR,
+ RestUtils::getRestErrorCodeFromException(new InvalidShortCodeException())
+ );
+ $this->assertEquals(
+ RestUtils::INVALID_URL_ERROR,
+ RestUtils::getRestErrorCodeFromException(new InvalidUrlException())
+ );
+ $this->assertEquals(
+ RestUtils::INVALID_ARGUMENT_ERROR,
+ RestUtils::getRestErrorCodeFromException(new InvalidArgumentException())
+ );
+ $this->assertEquals(
+ RestUtils::INVALID_CREDENTIALS_ERROR,
+ RestUtils::getRestErrorCodeFromException(new AuthenticationException())
+ );
+ $this->assertEquals(
+ RestUtils::UNKNOWN_ERROR,
+ RestUtils::getRestErrorCodeFromException(new WrongIpException())
+ );
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 06e1e939..ddd4f42b 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -20,6 +20,10 @@
./module/Core/src
./module/Rest/src
./module/CLI/src
+
+
+ ./module/Core/src/Repository
+