]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Implements Tags endpoints for Shaarli's REST API
authorArthurHoaro <arthur@hoa.ro>
Sat, 19 May 2018 13:04:04 +0000 (15:04 +0200)
committerArthurHoaro <arthur@hoa.ro>
Mon, 4 Jun 2018 16:51:22 +0000 (18:51 +0200)
Endpoints:

 * List All Tags [GET]
 * Get a tag [GET]
 * Update a tag [PUT]
 * Delete a tag [DELETE]

Fixes #904
References shaarli/api-documentation#34

16 files changed:
application/api/ApiUtils.php
application/api/controllers/Links.php
application/api/controllers/Tags.php [new file with mode: 0644]
application/api/exceptions/ApiTagNotFoundException.php [new file with mode: 0644]
index.php
tests/api/controllers/history/HistoryTest.php [moved from tests/api/controllers/HistoryTest.php with 100% similarity]
tests/api/controllers/info/InfoTest.php [moved from tests/api/controllers/InfoTest.php with 100% similarity]
tests/api/controllers/links/DeleteLinkTest.php [moved from tests/api/controllers/DeleteLinkTest.php with 100% similarity]
tests/api/controllers/links/GetLinkIdTest.php [moved from tests/api/controllers/GetLinkIdTest.php with 100% similarity]
tests/api/controllers/links/GetLinksTest.php [moved from tests/api/controllers/GetLinksTest.php with 100% similarity]
tests/api/controllers/links/PostLinkTest.php [moved from tests/api/controllers/PostLinkTest.php with 100% similarity]
tests/api/controllers/links/PutLinkTest.php [moved from tests/api/controllers/PutLinkTest.php with 100% similarity]
tests/api/controllers/tags/DeleteTagTest.php [new file with mode: 0644]
tests/api/controllers/tags/GetTagNameTest.php [new file with mode: 0644]
tests/api/controllers/tags/GetTagsTest.php [new file with mode: 0644]
tests/api/controllers/tags/PutTagTest.php [new file with mode: 0644]

index f154bb5274a224130e40d4a495dfb1334323a1b0..fc5ecaf1e75931d3a2006dae957be7129bb7df1e 100644 (file)
@@ -134,4 +134,20 @@ class ApiUtils
 
         return $oldLink;
     }
+
+    /**
+     * Format a Tag for the REST API.
+     *
+     * @param string $tag         Tag name
+     * @param int    $occurrences Number of links using this tag
+     *
+     * @return array Link data formatted for the REST API.
+     */
+    public static function formatTag($tag, $occurences)
+    {
+        return [
+            'name'       => $tag,
+            'occurrences' => $occurences,
+        ];
+    }
 }
index 3a9c03553a30fbe5247e48a2a6c30eb4f34e7b51..ffcfd4c75a4f8748101675247a138c083bd1dd27 100644 (file)
@@ -68,16 +68,16 @@ class Links extends ApiController
         }
 
         // 'environment' is set by Slim and encapsulate $_SERVER.
-        $index = index_url($this->ci['environment']);
+        $indexUrl = index_url($this->ci['environment']);
 
         $out = [];
-        $cpt = 0;
+        $index = 0;
         foreach ($links as $link) {
             if (count($out) >= $limit) {
                 break;
             }
-            if ($cpt++ >= $offset) {
-                $out[] = ApiUtils::formatLink($link, $index);
+            if ($index++ >= $offset) {
+                $out[] = ApiUtils::formatLink($link, $indexUrl);
             }
         }
 
diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php
new file mode 100644 (file)
index 0000000..6dd7875
--- /dev/null
@@ -0,0 +1,161 @@
+<?php
+
+namespace Shaarli\Api\Controllers;
+
+use Shaarli\Api\ApiUtils;
+use Shaarli\Api\Exceptions\ApiBadParametersException;
+use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
+use Shaarli\Api\Exceptions\ApiTagNotFoundException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class Tags
+ *
+ * REST API Controller: all services related to tags collection.
+ *
+ * @package Api\Controllers
+ */
+class Tags extends ApiController
+{
+    /**
+     * @var int Number of links returned if no limit is provided.
+     */
+    public static $DEFAULT_LIMIT = 'all';
+
+    /**
+     * Retrieve a list of tags, allowing different filters.
+     *
+     * @param Request  $request  Slim request.
+     * @param Response $response Slim response.
+     *
+     * @return Response response.
+     *
+     * @throws ApiBadParametersException Invalid parameters.
+     */
+    public function getTags($request, $response)
+    {
+        $visibility = $request->getParam('visibility');
+        $tags = $this->linkDb->linksCountPerTag([], $visibility);
+
+        // Return tags from the {offset}th tag, starting from 0.
+        $offset = $request->getParam('offset');
+        if (! empty($offset) && ! ctype_digit($offset)) {
+            throw new ApiBadParametersException('Invalid offset');
+        }
+        $offset = ! empty($offset) ? intval($offset) : 0;
+        if ($offset > count($tags)) {
+            return $response->withJson([], 200, $this->jsonStyle);
+        }
+
+        // limit parameter is either a number of links or 'all' for everything.
+        $limit = $request->getParam('limit');
+        if (empty($limit)) {
+            $limit = self::$DEFAULT_LIMIT;
+        }
+        if (ctype_digit($limit)) {
+            $limit = intval($limit);
+        } elseif ($limit === 'all') {
+            $limit = count($tags);
+        } else {
+            throw new ApiBadParametersException('Invalid limit');
+        }
+
+        $out = [];
+        $index = 0;
+        foreach ($tags as $tag => $occurrences) {
+            if (count($out) >= $limit) {
+                break;
+            }
+            if ($index++ >= $offset) {
+                $out[] = ApiUtils::formatTag($tag, $occurrences);
+            }
+        }
+
+        return $response->withJson($out, 200, $this->jsonStyle);
+    }
+
+    /**
+     * Return a single formatted tag by its name.
+     *
+     * @param Request  $request  Slim request.
+     * @param Response $response Slim response.
+     * @param array    $args     Path parameters. including the tag name.
+     *
+     * @return Response containing the link array.
+     *
+     * @throws ApiTagNotFoundException generating a 404 error.
+     */
+    public function getTag($request, $response, $args)
+    {
+        $tags = $this->linkDb->linksCountPerTag();
+        if (!isset($tags[$args['tagName']])) {
+            throw new ApiTagNotFoundException();
+        }
+        $out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]);
+
+        return $response->withJson($out, 200, $this->jsonStyle);
+    }
+
+    /**
+     * Rename a tag from the given name.
+     * If the new name provided matches an existing tag, they will be merged.
+     *
+     * @param Request  $request  Slim request.
+     * @param Response $response Slim response.
+     * @param array    $args     Path parameters. including the tag name.
+     *
+     * @return Response response.
+     *
+     * @throws ApiTagNotFoundException generating a 404 error.
+     * @throws ApiBadParametersException new tag name not provided
+     */
+    public function putTag($request, $response, $args)
+    {
+        $tags = $this->linkDb->linksCountPerTag();
+        if (! isset($tags[$args['tagName']])) {
+            throw new ApiTagNotFoundException();
+        }
+
+        $data = $request->getParsedBody();
+        if (empty($data['name'])) {
+            throw new ApiBadParametersException('New tag name is required in the request body');
+        }
+
+        $updated = $this->linkDb->renameTag($args['tagName'], $data['name']);
+        $this->linkDb->save($this->conf->get('resource.page_cache'));
+        foreach ($updated as $link) {
+            $this->history->updateLink($link);
+        }
+
+        $tags = $this->linkDb->linksCountPerTag();
+        $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
+        return $response->withJson($out, 200, $this->jsonStyle);
+    }
+
+    /**
+     * Delete an existing tag by its name.
+     *
+     * @param Request  $request  Slim request.
+     * @param Response $response Slim response.
+     * @param array    $args     Path parameters. including the tag name.
+     *
+     * @return Response response.
+     *
+     * @throws ApiTagNotFoundException generating a 404 error.
+     */
+    public function deleteTag($request, $response, $args)
+    {
+        $tags = $this->linkDb->linksCountPerTag();
+        if (! isset($tags[$args['tagName']])) {
+            throw new ApiTagNotFoundException();
+        }
+        $updated = $this->linkDb->renameTag($args['tagName'], null);
+        $this->linkDb->save($this->conf->get('resource.page_cache'));
+        foreach ($updated as $link) {
+            $this->history->updateLink($link);
+        }
+
+        return $response->withStatus(204);
+    }
+}
diff --git a/application/api/exceptions/ApiTagNotFoundException.php b/application/api/exceptions/ApiTagNotFoundException.php
new file mode 100644 (file)
index 0000000..eed5afa
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace Shaarli\Api\Exceptions;
+
+
+use Slim\Http\Response;
+
+/**
+ * Class ApiTagNotFoundException
+ *
+ * Tag selected by name couldn't be found in the datastore, results in a 404 error.
+ *
+ * @package Shaarli\Api\Exceptions
+ */
+class ApiTagNotFoundException extends ApiException
+{
+    /**
+     * ApiLinkNotFoundException constructor.
+     */
+    public function __construct()
+    {
+        $this->message = 'Tag not found';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getApiResponse()
+    {
+        return $this->buildApiResponse(404);
+    }
+}
index c34434ddcb0016b896330d63d19c7cdfc16fdd78..6dcec9b2b5ab1427bf21fa3e5d6115d0afc51eb1 100644 (file)
--- a/index.php
+++ b/index.php
@@ -2175,6 +2175,12 @@ $app->group('/api/v1', function() {
     $this->post('/links', '\Shaarli\Api\Controllers\Links:postLink')->setName('postLink');
     $this->put('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:putLink')->setName('putLink');
     $this->delete('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:deleteLink')->setName('deleteLink');
+
+    $this->get('/tags', '\Shaarli\Api\Controllers\Tags:getTags')->setName('getTags');
+    $this->get('/tags/{tagName:[\w]+}', '\Shaarli\Api\Controllers\Tags:getTag')->setName('getTag');
+    $this->put('/tags/{tagName:[\w]+}', '\Shaarli\Api\Controllers\Tags:putTag')->setName('putTag');
+    $this->delete('/tags/{tagName:[\w]+}', '\Shaarli\Api\Controllers\Tags:deleteTag')->setName('deleteTag');
+
     $this->get('/history', '\Shaarli\Api\Controllers\History:getHistory')->setName('getHistory');
 })->add('\Shaarli\Api\ApiMiddleware');
 
diff --git a/tests/api/controllers/tags/DeleteTagTest.php b/tests/api/controllers/tags/DeleteTagTest.php
new file mode 100644 (file)
index 0000000..7ba5a86
--- /dev/null
@@ -0,0 +1,164 @@
+<?php
+
+
+namespace Shaarli\Api\Controllers;
+
+use Shaarli\Config\ConfigManager;
+use Slim\Container;
+use Slim\Http\Environment;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DeleteTagTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testDatastore = 'sandbox/datastore.php';
+
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testHistory = 'sandbox/history.php';
+
+    /**
+     * @var ConfigManager instance
+     */
+    protected $conf;
+
+    /**
+     * @var \ReferenceLinkDB instance.
+     */
+    protected $refDB = null;
+
+    /**
+     * @var \LinkDB instance.
+     */
+    protected $linkDB;
+
+    /**
+     * @var \History instance.
+     */
+    protected $history;
+
+    /**
+     * @var Container instance.
+     */
+    protected $container;
+
+    /**
+     * @var Tags controller instance.
+     */
+    protected $controller;
+
+    /**
+     * Before each test, instantiate a new Api with its config, plugins and links.
+     */
+    public function setUp()
+    {
+        $this->conf = new ConfigManager('tests/utils/config/configJson');
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+        $this->linkDB = new \LinkDB(self::$testDatastore, true, false);
+        $refHistory = new \ReferenceHistory();
+        $refHistory->write(self::$testHistory);
+        $this->history = new \History(self::$testHistory);
+        $this->container = new Container();
+        $this->container['conf'] = $this->conf;
+        $this->container['db'] = $this->linkDB;
+        $this->container['history'] = $this->history;
+
+        $this->controller = new Tags($this->container);
+    }
+
+    /**
+     * After each test, remove the test datastore.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$testDatastore);
+        @unlink(self::$testHistory);
+    }
+
+    /**
+     * Test DELETE tag endpoint: the tag should be removed.
+     */
+    public function testDeleteTagValid()
+    {
+        $tagName = 'gnu';
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertTrue($tags[$tagName] > 0);
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'DELETE',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $response = $this->controller->deleteTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(204, $response->getStatusCode());
+        $this->assertEmpty((string) $response->getBody());
+
+        $this->linkDB = new \LinkDB(self::$testDatastore, true, false);
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertFalse(isset($tags[$tagName]));
+
+        // 2 links affected
+        $historyEntry = $this->history->getHistory()[0];
+        $this->assertEquals(\History::UPDATED, $historyEntry['event']);
+        $this->assertTrue(
+            (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime']
+        );
+        $historyEntry = $this->history->getHistory()[1];
+        $this->assertEquals(\History::UPDATED, $historyEntry['event']);
+        $this->assertTrue(
+            (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime']
+        );
+    }
+
+    /**
+     * Test DELETE tag endpoint: the tag should be removed.
+     */
+    public function testDeleteTagCaseSensitivity()
+    {
+        $tagName = 'sTuff';
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertTrue($tags[$tagName] > 0);
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'DELETE',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $response = $this->controller->deleteTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(204, $response->getStatusCode());
+        $this->assertEmpty((string) $response->getBody());
+
+        $this->linkDB = new \LinkDB(self::$testDatastore, true, false);
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertFalse(isset($tags[$tagName]));
+        $this->assertTrue($tags[strtolower($tagName)] > 0);
+
+        $historyEntry = $this->history->getHistory()[0];
+        $this->assertEquals(\History::UPDATED, $historyEntry['event']);
+        $this->assertTrue(
+            (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime']
+        );
+    }
+
+    /**
+     * Test DELETE link endpoint: reach not existing ID.
+     *
+     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
+     * @expectedExceptionMessage Tag not found
+     */
+    public function testDeleteLink404()
+    {
+        $tagName = 'nopenope';
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertFalse(isset($tags[$tagName]));
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'DELETE',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $this->controller->deleteTag($request, new Response(), ['tagName' => $tagName]);
+    }
+}
diff --git a/tests/api/controllers/tags/GetTagNameTest.php b/tests/api/controllers/tags/GetTagNameTest.php
new file mode 100644 (file)
index 0000000..d60f5b3
--- /dev/null
@@ -0,0 +1,129 @@
+<?php
+
+namespace Shaarli\Api\Controllers;
+
+use Shaarli\Config\ConfigManager;
+
+use Slim\Container;
+use Slim\Http\Environment;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class GetTagNameTest
+ *
+ * Test getTag by tag name API service.
+ *
+ * @package Shaarli\Api\Controllers
+ */
+class GetTagNameTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testDatastore = 'sandbox/datastore.php';
+
+    /**
+     * @var ConfigManager instance
+     */
+    protected $conf;
+
+    /**
+     * @var \ReferenceLinkDB instance.
+     */
+    protected $refDB = null;
+
+    /**
+     * @var Container instance.
+     */
+    protected $container;
+
+    /**
+     * @var Tags controller instance.
+     */
+    protected $controller;
+
+    /**
+     * Number of JSON fields per link.
+     */
+    const NB_FIELDS_TAG = 2;
+
+    /**
+     * Before each test, instantiate a new Api with its config, plugins and links.
+     */
+    public function setUp()
+    {
+        $this->conf = new ConfigManager('tests/utils/config/configJson');
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+
+        $this->container = new Container();
+        $this->container['conf'] = $this->conf;
+        $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
+        $this->container['history'] = null;
+
+        $this->controller = new Tags($this->container);
+    }
+
+    /**
+     * After each test, remove the test datastore.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$testDatastore);
+    }
+
+    /**
+     * Test basic getTag service: return gnu tag with 2 occurrences.
+     */
+    public function testGetTag()
+    {
+        $tagName = 'gnu';
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $response = $this->controller->getTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data));
+        $this->assertEquals($tagName, $data['name']);
+        $this->assertEquals(2, $data['occurrences']);
+    }
+
+    /**
+     * Test getTag service which is not case sensitive: occurrences with both sTuff and stuff
+     */
+    public function testGetTagNotCaseSensitive()
+    {
+        $tagName = 'sTuff';
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $response = $this->controller->getTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data));
+        $this->assertEquals($tagName, $data['name']);
+        $this->assertEquals(2, $data['occurrences']);
+    }
+
+    /**
+     * Test basic getLink service: get non existent link => ApiLinkNotFoundException.
+     *
+     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
+     * @expectedExceptionMessage Tag not found
+     */
+    public function testGetTag404()
+    {
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $this->controller->getTag($request, new Response(), ['tagName' => 'nopenope']);
+    }
+}
diff --git a/tests/api/controllers/tags/GetTagsTest.php b/tests/api/controllers/tags/GetTagsTest.php
new file mode 100644 (file)
index 0000000..cf066bc
--- /dev/null
@@ -0,0 +1,209 @@
+<?php
+namespace Shaarli\Api\Controllers;
+
+use Shaarli\Config\ConfigManager;
+
+use Slim\Container;
+use Slim\Http\Environment;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class GetTagsTest
+ *
+ * Test get tag list REST API service.
+ *
+ * @package Shaarli\Api\Controllers
+ */
+class GetTagsTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testDatastore = 'sandbox/datastore.php';
+
+    /**
+     * @var ConfigManager instance
+     */
+    protected $conf;
+
+    /**
+     * @var \ReferenceLinkDB instance.
+     */
+    protected $refDB = null;
+
+    /**
+     * @var Container instance.
+     */
+    protected $container;
+
+    /**
+     * @var \LinkDB instance.
+     */
+    protected $linkDB;
+
+    /**
+     * @var Tags controller instance.
+     */
+    protected $controller;
+
+    /**
+     * Number of JSON field per link.
+     */
+    const NB_FIELDS_TAG = 2;
+
+    /**
+     * Before every test, instantiate a new Api with its config, plugins and links.
+     */
+    public function setUp()
+    {
+        $this->conf = new ConfigManager('tests/utils/config/configJson');
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+
+        $this->container = new Container();
+        $this->container['conf'] = $this->conf;
+        $this->linkDB = new \LinkDB(self::$testDatastore, true, false);
+        $this->container['db'] = $this->linkDB;
+        $this->container['history'] = null;
+
+        $this->controller = new Tags($this->container);
+    }
+
+    /**
+     * After every test, remove the test datastore.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$testDatastore);
+    }
+
+    /**
+     * Test basic getLinks service: returns all tags.
+     */
+    public function testGetTagsAll()
+    {
+        $tags = $this->linkDB->linksCountPerTag();
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(count($tags), count($data));
+
+        // Check order
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[0]));
+        $this->assertEquals('web', $data[0]['name']);
+        $this->assertEquals(4, $data[0]['occurrences']);
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[1]));
+        $this->assertEquals('cartoon', $data[1]['name']);
+        $this->assertEquals(3, $data[1]['occurrences']);
+        // Case insensitive
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[2]));
+        $this->assertEquals('sTuff', $data[2]['name']);
+        $this->assertEquals(2, $data[2]['occurrences']);
+        // End
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[count($data) - 1]));
+        $this->assertEquals('ut', $data[count($data) - 1]['name']);
+        $this->assertEquals(1, $data[count($data) - 1]['occurrences']);
+    }
+
+    /**
+     * Test getTags service with offset and limit parameter:
+     *   limit=1 and offset=1 should return only the second tag, cartoon with 3 occurrences
+     */
+    public function testGetTagsOffsetLimit()
+    {
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'QUERY_STRING' => 'offset=1&limit=1'
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(1, count($data));
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[0]));
+        $this->assertEquals('cartoon', $data[0]['name']);
+        $this->assertEquals(3, $data[0]['occurrences']);
+    }
+
+    /**
+     * Test getTags with limit=all (return all tags).
+     */
+    public function testGetTagsLimitAll()
+    {
+        $tags = $this->linkDB->linksCountPerTag();
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'QUERY_STRING' => 'limit=all'
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(count($tags), count($data));
+    }
+
+    /**
+     * Test getTags service with offset and limit parameter:
+     *   limit=1 and offset=1 should not return any tag
+     */
+    public function testGetTagsOffsetTooHigh()
+    {
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'QUERY_STRING' => 'offset=100'
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEmpty(count($data));
+    }
+
+    /**
+     * Test getTags with visibility parameter set to private
+     */
+    public function testGetTagsVisibilityPrivate()
+    {
+        $tags = $this->linkDB->linksCountPerTag([], 'private');
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'QUERY_STRING' => 'visibility=private'
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(count($tags), count($data));
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[0]));
+        $this->assertEquals('css', $data[0]['name']);
+        $this->assertEquals(1, $data[0]['occurrences']);
+    }
+
+    /**
+     * Test getTags with visibility parameter set to public
+     */
+    public function testGetTagsVisibilityPublic()
+    {
+        $tags = $this->linkDB->linksCountPerTag([], 'public');
+        $env = Environment::mock(
+            [
+                'REQUEST_METHOD' => 'GET',
+                'QUERY_STRING' => 'visibility=public'
+            ]
+        );
+        $request = Request::createFromEnvironment($env);
+        $response = $this->controller->getTags($request, new Response());
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string)$response->getBody(), true);
+        $this->assertEquals(count($tags), count($data));
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data[0]));
+        $this->assertEquals('web', $data[0]['name']);
+        $this->assertEquals(3, $data[0]['occurrences']);
+    }
+}
diff --git a/tests/api/controllers/tags/PutTagTest.php b/tests/api/controllers/tags/PutTagTest.php
new file mode 100644 (file)
index 0000000..6f7dec2
--- /dev/null
@@ -0,0 +1,209 @@
+<?php
+
+
+namespace Shaarli\Api\Controllers;
+
+
+use Shaarli\Api\Exceptions\ApiBadParametersException;
+use Shaarli\Config\ConfigManager;
+use Slim\Container;
+use Slim\Http\Environment;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PutTagTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testDatastore = 'sandbox/datastore.php';
+
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testHistory = 'sandbox/history.php';
+
+    /**
+     * @var ConfigManager instance
+     */
+    protected $conf;
+
+    /**
+     * @var \ReferenceLinkDB instance.
+     */
+    protected $refDB = null;
+
+    /**
+     * @var \History instance.
+     */
+    protected $history;
+
+    /**
+     * @var Container instance.
+     */
+    protected $container;
+
+    /**
+     * @var \LinkDB instance.
+     */
+    protected $linkDB;
+
+    /**
+     * @var Tags controller instance.
+     */
+    protected $controller;
+
+    /**
+     * Number of JSON field per link.
+     */
+    const NB_FIELDS_TAG = 2;
+
+    /**
+     * Before every test, instantiate a new Api with its config, plugins and links.
+     */
+    public function setUp()
+    {
+        $this->conf = new ConfigManager('tests/utils/config/configJson.json.php');
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+
+        $refHistory = new \ReferenceHistory();
+        $refHistory->write(self::$testHistory);
+        $this->history = new \History(self::$testHistory);
+
+        $this->container = new Container();
+        $this->container['conf'] = $this->conf;
+        $this->linkDB = new \LinkDB(self::$testDatastore, true, false);
+        $this->container['db'] = $this->linkDB;
+        $this->container['history'] = $this->history;
+
+        $this->controller = new Tags($this->container);
+    }
+
+    /**
+     * After every test, remove the test datastore.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$testDatastore);
+        @unlink(self::$testHistory);
+    }
+
+    /**
+     * Test tags update
+     */
+    public function testPutLinkValid()
+    {
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'PUT',
+        ]);
+        $tagName = 'gnu';
+        $update = ['name' => $newName = 'newtag'];
+        $request = Request::createFromEnvironment($env);
+        $request = $request->withParsedBody($update);
+
+        $response = $this->controller->putTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data));
+        $this->assertEquals($newName, $data['name']);
+        $this->assertEquals(2, $data['occurrences']);
+
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertNotTrue(isset($tags[$tagName]));
+        $this->assertEquals(2, $tags[$newName]);
+
+        $historyEntry = $this->history->getHistory()[0];
+        $this->assertEquals(\History::UPDATED, $historyEntry['event']);
+        $this->assertTrue(
+            (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime']
+        );
+        $historyEntry = $this->history->getHistory()[1];
+        $this->assertEquals(\History::UPDATED, $historyEntry['event']);
+        $this->assertTrue(
+            (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime']
+        );
+    }
+
+    /**
+     * Test tag update with an existing tag: they should be merged
+     */
+    public function testPutTagMerge()
+    {
+        $tagName = 'gnu';
+        $newName = 'w3c';
+
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertEquals(1, $tags[$newName]);
+        $this->assertEquals(2, $tags[$tagName]);
+
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'PUT',
+        ]);
+        $update = ['name' => $newName];
+        $request = Request::createFromEnvironment($env);
+        $request = $request->withParsedBody($update);
+
+        $response = $this->controller->putTag($request, new Response(), ['tagName' => $tagName]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $data = json_decode((string) $response->getBody(), true);
+        $this->assertEquals(self::NB_FIELDS_TAG, count($data));
+        $this->assertEquals($newName, $data['name']);
+        $this->assertEquals(3, $data['occurrences']);
+
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertNotTrue(isset($tags[$tagName]));
+        $this->assertEquals(3, $tags[$newName]);
+    }
+
+    /**
+     * Test tag update with an empty new tag name => ApiBadParametersException
+     *
+     * @expectedException Shaarli\Api\Exceptions\ApiBadParametersException
+     * @expectedExceptionMessage New tag name is required in the request body
+     */
+    public function testPutTagEmpty()
+    {
+        $tagName = 'gnu';
+        $newName = '';
+
+        $tags = $this->linkDB->linksCountPerTag();
+        $this->assertEquals(2, $tags[$tagName]);
+
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'PUT',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'PUT',
+        ]);
+        $update = ['name' => $newName];
+        $request = Request::createFromEnvironment($env);
+        $request = $request->withParsedBody($update);
+
+        try {
+            $this->controller->putTag($request, new Response(), ['tagName' => $tagName]);
+        } catch (ApiBadParametersException $e) {
+            $tags = $this->linkDB->linksCountPerTag();
+            $this->assertEquals(2, $tags[$tagName]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Test tag update on non existent tag => ApiTagNotFoundException.
+     *
+     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
+     * @expectedExceptionMessage Tag not found
+     */
+    public function testPutTag404()
+    {
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'PUT',
+        ]);
+        $request = Request::createFromEnvironment($env);
+
+        $this->controller->putTag($request, new Response(), ['tagName' => 'nopenope']);
+    }
+}