From a9813b1ab9ab6eb1b3435bfc6e46324f8c04ee07 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya <alejandro@alejandrocelaya.com>
Date: Sun, 17 Apr 2016 13:42:52 +0200
Subject: [PATCH] Implemented UrlShortener main service

---
 .travis.yml                                 |  28 ----
 composer.json                               |   2 +-
 phpunit.xml.dist                            |   4 +-
 src/Entity/ShortUrl.php                     |   6 +-
 src/Exception/InvalidShortCodeException.php |  15 +++
 src/Service/UrlShortener.php                |  28 +++-
 src/Service/UrlShortenerInterface.php       |   8 +-
 src/Service/VisitsTrackerInterface.php      |  13 ++
 tests/Service/UrlShortenerTest.php          | 136 ++++++++++++++++++++
 9 files changed, 198 insertions(+), 42 deletions(-)
 delete mode 100644 .travis.yml
 create mode 100644 src/Exception/InvalidShortCodeException.php
 create mode 100644 src/Service/VisitsTrackerInterface.php
 create mode 100644 tests/Service/UrlShortenerTest.php

diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4d90fc81..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-sudo: false
-
-language: php
-
-matrix:
-  fast_finish: true
-  include:
-    - php: 5.5
-    - php: 5.6
-      env:
-        - EXECUTE_CS_CHECK=true
-    - php: 7
-    - php: hhvm
-  allow_failures:
-    - php: hhvm
-
-before_install:
-  - composer self-update
-
-install:
-  - travis_retry composer install --no-interaction --ignore-platform-reqs --prefer-source --no-scripts
-
-script:
-  - composer test
-  - if [[ $EXECUTE_CS_CHECK == 'true' ]]; then composer cs ; fi
-
-notifications:
-  email: true
diff --git a/composer.json b/composer.json
index 96fc1489..a1b5cf56 100644
--- a/composer.json
+++ b/composer.json
@@ -47,6 +47,6 @@
         "cs-fix": "phpcbf",
         "serve": "php -S 0.0.0.0:8000 -t public/",
         "test": "phpunit",
-        "pretty-test": "phpunit -c tests/phpunit.xml --coverage-html build/coverage"
+        "pretty-test": "phpunit --coverage-html build/coverage"
     }
 }
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index bb69885f..0721b191 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,7 +1,7 @@
 <phpunit bootstrap="./vendor/autoload.php" colors="true">
     <testsuites>
-        <testsuite name="App\\Tests">
-            <directory>./test</directory>
+        <testsuite name="AcelayaTest">
+            <directory>./tests</directory>
         </testsuite>
     </testsuites>
 
diff --git a/src/Entity/ShortUrl.php b/src/Entity/ShortUrl.php
index cdf7c983..994f2e2f 100644
--- a/src/Entity/ShortUrl.php
+++ b/src/Entity/ShortUrl.php
@@ -41,9 +41,9 @@ class ShortUrl extends AbstractEntity
      */
     public function __construct()
     {
-        $this->dateCreated = new \DateTime();
-        $this->visits = new ArrayCollection();
-        $this->shortCode = '';
+        $this->setDateCreated(new \DateTime());
+        $this->setVisits(new ArrayCollection());
+        $this->setShortCode('');
     }
 
     /**
diff --git a/src/Exception/InvalidShortCodeException.php b/src/Exception/InvalidShortCodeException.php
new file mode 100644
index 00000000..b41038a1
--- /dev/null
+++ b/src/Exception/InvalidShortCodeException.php
@@ -0,0 +1,15 @@
+<?php
+namespace Acelaya\UrlShortener\Exception;
+
+class InvalidShortCodeException extends RuntimeException
+{
+    public static function fromShortCode($shortCode, $charSet, \Exception $previous = null)
+    {
+        $code = isset($previous) ? $previous->getCode() : -1;
+        return new static(
+            sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
+            $code,
+            $previous
+        );
+    }
+}
diff --git a/src/Service/UrlShortener.php b/src/Service/UrlShortener.php
index 0364bf5c..55953691 100644
--- a/src/Service/UrlShortener.php
+++ b/src/Service/UrlShortener.php
@@ -2,6 +2,7 @@
 namespace Acelaya\UrlShortener\Service;
 
 use Acelaya\UrlShortener\Entity\ShortUrl;
+use Acelaya\UrlShortener\Exception\InvalidShortCodeException;
 use Acelaya\UrlShortener\Exception\InvalidUrlException;
 use Acelaya\UrlShortener\Exception\RuntimeException;
 use Doctrine\ORM\EntityManagerInterface;
@@ -12,7 +13,7 @@ use Psr\Http\Message\UriInterface;
 
 class UrlShortener implements UrlShortenerInterface
 {
-    const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
+    const DEFAULT_CHARS = 'rYHxLkXfsptbNZzKDG4hy85WFT7BRgMVdC9jvwQPnc6S32Jqm';
 
     /**
      * @var ClientInterface
@@ -38,6 +39,8 @@ class UrlShortener implements UrlShortenerInterface
     }
 
     /**
+     * Creates and persists a unique shortcode generated for provided url
+     *
      * @param UriInterface $url
      * @return string
      * @throws InvalidUrlException
@@ -106,26 +109,37 @@ class UrlShortener implements UrlShortenerInterface
      */
     protected function convertAutoincrementIdToShortCode($id)
     {
-        $id = intval($id);
+        $id = intval($id) + 200000; // Increment the Id so that the generated shortcode is not too short
         $length = strlen($this->chars);
         $code = '';
 
-        while ($id > $length - 1) {
+        while ($id > 0) {
             // Determine the value of the next higher character in the short code and prepend it
-            $code = $this->chars[fmod($id, $length)] . $code;
+            $code = $this->chars[intval(fmod($id, $length))] . $code;
             $id = floor($id / $length);
         }
 
-        return $this->chars[$id] . $code;
+        return $this->chars[intval($id)] . $code;
     }
 
     /**
+     * Tries to find the mapped URL for provided short code. Returns null if not found
+     *
      * @param string $shortCode
-     * @return string
+     * @return string|null
+     * @throws InvalidShortCodeException
      */
     public function shortCodeToUrl($shortCode)
     {
         // Validate short code format
-        
+        if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) {
+            throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars);
+        }
+
+        /** @var ShortUrl $shortUrl */
+        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
+            'shortCode' => $shortCode,
+        ]);
+        return isset($shortUrl) ? $shortUrl->getOriginalUrl() : null;
     }
 }
diff --git a/src/Service/UrlShortenerInterface.php b/src/Service/UrlShortenerInterface.php
index 6c211c10..6623f392 100644
--- a/src/Service/UrlShortenerInterface.php
+++ b/src/Service/UrlShortenerInterface.php
@@ -1,6 +1,7 @@
 <?php
 namespace Acelaya\UrlShortener\Service;
 
+use Acelaya\UrlShortener\Exception\InvalidShortCodeException;
 use Acelaya\UrlShortener\Exception\InvalidUrlException;
 use Acelaya\UrlShortener\Exception\RuntimeException;
 use Psr\Http\Message\UriInterface;
@@ -8,6 +9,8 @@ use Psr\Http\Message\UriInterface;
 interface UrlShortenerInterface
 {
     /**
+     * Creates and persists a unique shortcode generated for provided url
+     *
      * @param UriInterface $url
      * @return string
      * @throws InvalidUrlException
@@ -16,8 +19,11 @@ interface UrlShortenerInterface
     public function urlToShortCode(UriInterface $url);
 
     /**
+     * Tries to find the mapped URL for provided short code. Returns null if not found
+     *
      * @param string $shortCode
-     * @return string
+     * @return string|null
+     * @throws InvalidShortCodeException
      */
     public function shortCodeToUrl($shortCode);
 }
diff --git a/src/Service/VisitsTrackerInterface.php b/src/Service/VisitsTrackerInterface.php
new file mode 100644
index 00000000..3b2fc874
--- /dev/null
+++ b/src/Service/VisitsTrackerInterface.php
@@ -0,0 +1,13 @@
+<?php
+namespace Acelaya\UrlShortener\Service;
+
+interface VisitsTrackerInterface
+{
+    /**
+     * Tracks a new visit to provided short code, using an array of data to look up information
+     *
+     * @param string $shortCode
+     * @param array $visitorData Defaults to global $_SERVER
+     */
+    public function track($shortCode, array $visitorData = null);
+}
diff --git a/tests/Service/UrlShortenerTest.php b/tests/Service/UrlShortenerTest.php
new file mode 100644
index 00000000..010692a4
--- /dev/null
+++ b/tests/Service/UrlShortenerTest.php
@@ -0,0 +1,136 @@
+<?php
+namespace AcelayaTest\UrlShortener\Service;
+
+use Acelaya\UrlShortener\Entity\ShortUrl;
+use Acelaya\UrlShortener\Service\UrlShortener;
+use Doctrine\Common\Persistence\ObjectRepository;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\ORMException;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ClientException;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\Psr7\Response;
+use PHPUnit_Framework_TestCase as TestCase;
+use Prophecy\Argument;
+use Prophecy\Prophecy\ObjectProphecy;
+use Zend\Diactoros\Uri;
+
+class UrlShortenerTest extends TestCase
+{
+    /**
+     * @var UrlShortener
+     */
+    protected $urlShortener;
+    /**
+     * @var ObjectProphecy
+     */
+    protected $em;
+    /**
+     * @var ObjectProphecy
+     */
+    protected $httpClient;
+
+    public function setUp()
+    {
+        $this->httpClient = $this->prophesize(ClientInterface::class);
+
+        $this->em = $this->prophesize(EntityManagerInterface::class);
+        $conn = $this->prophesize(Connection::class);
+        $conn->isTransactionActive()->willReturn(false);
+        $this->em->getConnection()->willReturn($conn->reveal());
+        $this->em->flush()->willReturn(null);
+        $this->em->commit()->willReturn(null);
+        $this->em->beginTransaction()->willReturn(null);
+        $this->em->persist(Argument::any())->will(function ($arguments) {
+            /** @var ShortUrl $shortUrl */
+            $shortUrl = $arguments[0];
+            $shortUrl->setId(10);
+        });
+        $repo = $this->prophesize(ObjectRepository::class);
+        $repo->findOneBy(Argument::any())->willReturn(null);
+        $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
+
+        $this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal());
+    }
+
+    /**
+     * @test
+     */
+    public function urlIsProperlyShortened()
+    {
+        // 10 -> rY9zc
+        $shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
+        $this->assertEquals('rY9zc', $shortCode);
+    }
+
+    /**
+     * @test
+     * @expectedException \Acelaya\UrlShortener\Exception\RuntimeException
+     */
+    public function exceptionIsThrownWhenOrmThrowsException()
+    {
+        $conn = $this->prophesize(Connection::class);
+        $conn->isTransactionActive()->willReturn(true);
+        $this->em->getConnection()->willReturn($conn->reveal());
+        $this->em->rollback()->shouldBeCalledTimes(1);
+        $this->em->close()->shouldBeCalledTimes(1);
+
+        $this->em->flush()->willThrow(new ORMException());
+        $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
+    }
+
+    /**
+     * @test
+     * @expectedException \Acelaya\UrlShortener\Exception\InvalidUrlException
+     */
+    public function exceptionIsThrownWhenUrlDoesNotExist()
+    {
+        $this->httpClient->request(Argument::cetera())->willThrow(
+            new ClientException('', $this->prophesize(Request::class)->reveal())
+        );
+        $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
+    }
+
+    /**
+     * @test
+     */
+    public function whenShortUrlExistsItsShortcodeIsReturned()
+    {
+        $shortUrl = new ShortUrl();
+        $shortUrl->setShortCode('expected_shortcode');
+        $repo = $this->prophesize(ObjectRepository::class);
+        $repo->findOneBy(Argument::any())->willReturn($shortUrl);
+        $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
+
+        $shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
+        $this->assertEquals($shortUrl->getShortCode(), $shortCode);
+    }
+
+    /**
+     * @test
+     */
+    public function shortCodeIsProperlyParsed()
+    {
+        // rY9zc -> 10
+        $shortUrl = new ShortUrl();
+        $shortUrl->setShortCode('rY9zc')
+                 ->setOriginalUrl('expected_url');
+
+        $repo = $this->prophesize(ObjectRepository::class);
+        $repo->findOneBy(['shortCode' => 'rY9zc'])->willReturn($shortUrl);
+        $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
+
+        $url = $this->urlShortener->shortCodeToUrl('rY9zc');
+        $this->assertEquals($shortUrl->getOriginalUrl(), $url);
+    }
+
+    /**
+     * @test
+     * @expectedException \Acelaya\UrlShortener\Exception\InvalidShortCodeException
+     */
+    public function invalidCharSetThrowsException()
+    {
+        $this->urlShortener->shortCodeToUrl('&/(');
+    }
+}