From fe3713d2e5c91e2d07af72b39f321521d3dd470c Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 01:35:14 +0100 Subject: namespacing: move LinkUtils along \Shaarli\Bookmark classes Signed-off-by: VirtualTam --- application/LinkUtils.php | 222 ------------------ application/bookmark/LinkUtils.php | 222 ++++++++++++++++++ index.php | 2 +- tests/LinkUtilsTest.php | 421 ----------------------------------- tests/bookmark/LinkUtilsTest.php | 333 +++++++++++++++++++++++++++ tests/plugins/PluginMarkdownTest.php | 1 + tests/utils/CurlUtils.php | 94 ++++++++ 7 files changed, 651 insertions(+), 644 deletions(-) delete mode 100644 application/LinkUtils.php create mode 100644 application/bookmark/LinkUtils.php delete mode 100644 tests/LinkUtilsTest.php create mode 100644 tests/bookmark/LinkUtilsTest.php create mode 100644 tests/utils/CurlUtils.php diff --git a/application/LinkUtils.php b/application/LinkUtils.php deleted file mode 100644 index b5110edc..00000000 --- a/application/LinkUtils.php +++ /dev/null @@ -1,222 +0,0 @@ -(.*?)!is', $html, $matches)) { - return trim(str_replace("\n", '', $matches[1])); - } - return false; -} - -/** - * Extract charset from HTTP header if it's defined. - * - * @param string $header HTTP header Content-Type line. - * - * @return bool|string Charset string if found (lowercase), false otherwise. - */ -function header_extract_charset($header) -{ - preg_match('/charset="?([^; ]+)/i', $header, $match); - if (! empty($match[1])) { - return strtolower(trim($match[1])); - } - - return false; -} - -/** - * Extract charset HTML content (tag ). - * - * @param string $html HTML content where to look for charset. - * - * @return bool|string Charset string if found, false otherwise. - */ -function html_extract_charset($html) -{ - // Get encoding specified in HTML header. - preg_match('#/]+)["\']? */?>#Usi', $html, $enc); - if (!empty($enc[1])) { - return strtolower($enc[1]); - } - - return false; -} - -/** - * Count private links in given linklist. - * - * @param array|Countable $links Linklist. - * - * @return int Number of private links. - */ -function count_private($links) -{ - $cpt = 0; - foreach ($links as $link) { - if ($link['private']) { - $cpt += 1; - } - } - - return $cpt; -} - -/** - * In a string, converts URLs to clickable links. - * - * @param string $text input string. - * @param string $redirector if a redirector is set, use it to gerenate links. - * @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not. - * - * @return string returns $text with all links converted to HTML links. - * - * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 - */ -function text2clickable($text, $redirector = '', $urlEncode = true) -{ - $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si'; - - if (empty($redirector)) { - return preg_replace($regex, '$1', $text); - } - // Redirector is set, urlencode the final URL. - return preg_replace_callback( - $regex, - function ($matches) use ($redirector, $urlEncode) { - $url = $urlEncode ? urlencode($matches[1]) : $matches[1]; - return ''. $matches[1] .''; - }, - $text - ); -} - -/** - * Auto-link hashtags. - * - * @param string $description Given description. - * @param string $indexUrl Root URL. - * - * @return string Description with auto-linked hashtags. - */ -function hashtag_autolink($description, $indexUrl = '') -{ - /* - * To support unicode: http://stackoverflow.com/a/35498078/1484919 - * \p{Pc} - to match underscore - * \p{N} - numeric character in any script - * \p{L} - letter from any language - * \p{Mn} - any non marking space (accents, umlauts, etc) - */ - $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1#$2'; - return preg_replace($regex, $replacement, $description); -} - -/** - * This function inserts   where relevant so that multiple spaces are properly displayed in HTML - * even in the absence of
  (This is used in description to keep text formatting).
- *
- * @param string $text input text.
- *
- * @return string formatted text.
- */
-function space2nbsp($text)
-{
-    return preg_replace('/(^| ) /m', '$1 ', $text);
-}
-
-/**
- * Format Shaarli's description
- *
- * @param string $description shaare's description.
- * @param string $redirector  if a redirector is set, use it to gerenate links.
- * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
- * @param string $indexUrl    URL to Shaarli's index.
-
- * @return string formatted description.
- */
-function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '')
-{
-    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
-}
-
-/**
- * Generate a small hash for a link.
- *
- * @param DateTime $date Link creation date.
- * @param int      $id   Link ID.
- *
- * @return string the small hash generated from link data.
- */
-function link_small_hash($date, $id)
-{
-    return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
-}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
new file mode 100644
index 00000000..de5b61cb
--- /dev/null
+++ b/application/bookmark/LinkUtils.php
@@ -0,0 +1,222 @@
+(.*?)!is', $html, $matches)) {
+        return trim(str_replace("\n", '', $matches[1]));
+    }
+    return false;
+}
+
+/**
+ * Extract charset from HTTP header if it's defined.
+ *
+ * @param string $header HTTP header Content-Type line.
+ *
+ * @return bool|string Charset string if found (lowercase), false otherwise.
+ */
+function header_extract_charset($header)
+{
+    preg_match('/charset="?([^; ]+)/i', $header, $match);
+    if (! empty($match[1])) {
+        return strtolower(trim($match[1]));
+    }
+
+    return false;
+}
+
+/**
+ * Extract charset HTML content (tag ).
+ *
+ * @param string $html HTML content where to look for charset.
+ *
+ * @return bool|string Charset string if found, false otherwise.
+ */
+function html_extract_charset($html)
+{
+    // Get encoding specified in HTML header.
+    preg_match('#/]+)["\']? */?>#Usi', $html, $enc);
+    if (!empty($enc[1])) {
+        return strtolower($enc[1]);
+    }
+
+    return false;
+}
+
+/**
+ * Count private links in given linklist.
+ *
+ * @param array|Countable $links Linklist.
+ *
+ * @return int Number of private links.
+ */
+function count_private($links)
+{
+    $cpt = 0;
+    foreach ($links as $link) {
+        if ($link['private']) {
+            $cpt += 1;
+        }
+    }
+
+    return $cpt;
+}
+
+/**
+ * In a string, converts URLs to clickable links.
+ *
+ * @param string $text       input string.
+ * @param string $redirector if a redirector is set, use it to gerenate links.
+ * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
+ *
+ * @return string returns $text with all links converted to HTML links.
+ *
+ * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
+ */
+function text2clickable($text, $redirector = '', $urlEncode = true)
+{
+    $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
+
+    if (empty($redirector)) {
+        return preg_replace($regex, '$1', $text);
+    }
+    // Redirector is set, urlencode the final URL.
+    return preg_replace_callback(
+        $regex,
+        function ($matches) use ($redirector, $urlEncode) {
+            $url = $urlEncode ? urlencode($matches[1]) : $matches[1];
+            return ''. $matches[1] .'';
+        },
+        $text
+    );
+}
+
+/**
+ * Auto-link hashtags.
+ *
+ * @param string $description Given description.
+ * @param string $indexUrl    Root URL.
+ *
+ * @return string Description with auto-linked hashtags.
+ */
+function hashtag_autolink($description, $indexUrl = '')
+{
+    /*
+     * To support unicode: http://stackoverflow.com/a/35498078/1484919
+     * \p{Pc} - to match underscore
+     * \p{N} - numeric character in any script
+     * \p{L} - letter from any language
+     * \p{Mn} - any non marking space (accents, umlauts, etc)
+     */
+    $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
+    $replacement = '$1#$2';
+    return preg_replace($regex, $replacement, $description);
+}
+
+/**
+ * This function inserts   where relevant so that multiple spaces are properly displayed in HTML
+ * even in the absence of 
  (This is used in description to keep text formatting).
+ *
+ * @param string $text input text.
+ *
+ * @return string formatted text.
+ */
+function space2nbsp($text)
+{
+    return preg_replace('/(^| ) /m', '$1 ', $text);
+}
+
+/**
+ * Format Shaarli's description
+ *
+ * @param string $description shaare's description.
+ * @param string $redirector  if a redirector is set, use it to gerenate links.
+ * @param bool   $urlEncode   Use `urlencode()` on the URL after the redirector or not.
+ * @param string $indexUrl    URL to Shaarli's index.
+
+ * @return string formatted description.
+ */
+function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '')
+{
+    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
+}
+
+/**
+ * Generate a small hash for a link.
+ *
+ * @param DateTime $date Link creation date.
+ * @param int      $id   Link ID.
+ *
+ * @return string the small hash generated from link data.
+ */
+function link_small_hash($date, $id)
+{
+    return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
+}
diff --git a/index.php b/index.php
index dbb3c6fc..146b4457 100644
--- a/index.php
+++ b/index.php
@@ -57,13 +57,13 @@ require_once __DIR__ . '/vendor/autoload.php';
 
 // Shaarli library
 require_once 'application/ApplicationUtils.php';
+require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/config/ConfigPlugin.php';
 require_once 'application/feed/Cache.php';
 require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
 require_once 'application/FileUtils.php';
 require_once 'application/History.php';
-require_once 'application/LinkUtils.php';
 require_once 'application/NetscapeBookmarkUtils.php';
 require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
diff --git a/tests/LinkUtilsTest.php b/tests/LinkUtilsTest.php
deleted file mode 100644
index 5407159a..00000000
--- a/tests/LinkUtilsTest.php
+++ /dev/null
@@ -1,421 +0,0 @@
-stuff'. $title .'';
-        $this->assertEquals($title, html_extract_title($html));
-        $html = ''. $title .'blablaanother';
-        $this->assertEquals($title, html_extract_title($html));
-    }
-
-    /**
-     * Test html_extract_title() when the title is not found.
-     */
-    public function testHtmlExtractNonExistentTitle()
-    {
-        $html = 'stuff';
-        $this->assertFalse(html_extract_title($html));
-    }
-
-    /**
-     * Test headers_extract_charset() when the charset is found.
-     */
-    public function testHeadersExtractExistentCharset()
-    {
-        $charset = 'x-MacCroatian';
-        $headers = 'text/html; charset='. $charset;
-        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
-    }
-
-    /**
-     * Test headers_extract_charset() when the charset is not found.
-     */
-    public function testHeadersExtractNonExistentCharset()
-    {
-        $headers = '';
-        $this->assertFalse(header_extract_charset($headers));
-
-        $headers = 'text/html';
-        $this->assertFalse(header_extract_charset($headers));
-    }
-
-    /**
-     * Test html_extract_charset() when the charset is found.
-     */
-    public function testHtmlExtractExistentCharset()
-    {
-        $charset = 'x-MacCroatian';
-        $html = 'stuff2';
-        $this->assertEquals(strtolower($charset), html_extract_charset($html));
-    }
-
-    /**
-     * Test html_extract_charset() when the charset is not found.
-     */
-    public function testHtmlExtractNonExistentCharset()
-    {
-        $html = 'stuff';
-        $this->assertFalse(html_extract_charset($html));
-        $html = 'stuff';
-        $this->assertFalse(html_extract_charset($html));
-    }
-
-    /**
-     * Test the download callback with valid value
-     */
-    public function testCurlDownloadCallbackOk()
-    {
-        $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
-        $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
-            'end' => 'th=device-width">'
-            .'Refactoring · GitHub'
-            .''
-            .'Refactoring · GitHub'
-            .'',
-            'end' => 'th=device-width">'
-            .'Refactoring · GitHub'
-            .'Refactoring · GitHub'
-            .'http://hello.there/is=someone#here otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-
-        $text = 'stuff http://hello.there/is=someone#here(please) otherstuff';
-        $expectedText = 'stuff '
-            .'http://hello.there/is=someone#here(please) otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-
-        $text = 'stuff http://hello.there/is=someone#here(please)&no otherstuff';
-        $expectedText = 'stuff '
-            .'http://hello.there/is=someone#here(please)&no otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test text2clickable with a redirector set.
-     */
-    public function testText2clickableWithRedirector()
-    {
-        $text = 'stuff http://hello.there/is=someone#here otherstuff';
-        $redirector = 'http://redirector.to';
-        $expectedText = 'stuff http://hello.there/is=someone#here otherstuff';
-        $processedText = text2clickable($text, $redirector);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test text2clickable a redirector set and without URL encode.
-     */
-    public function testText2clickableWithRedirectorDontEncode()
-    {
-        $text = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
-        $redirector = 'http://redirector.to';
-        $expectedText = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
-        $processedText = text2clickable($text, $redirector, false);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test testSpace2nbsp.
-     */
-    public function testSpace2nbsp()
-    {
-        $text = '  Are you   thrilled  by flags   ?'. PHP_EOL .' Really?';
-        $expectedText = '  Are you   thrilled  by flags   ?'. PHP_EOL .' Really?';
-        $processedText = space2nbsp($text);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test hashtags auto-link.
-     */
-    public function testHashtagAutolink()
-    {
-        $index = 'http://domain.tld/';
-        $rawDescription = '#hashtag\n
-            # nothashtag\n
-            test#nothashtag #hashtag \#nothashtag\n
-            test #hashtag #hashtag test #hashtag.test\n
-            #hashtag #hashtag-nothashtag #hashtag_hashtag\n
-            What is #ашок anyway?\n
-            カタカナ #カタカナ」カタカナ\n';
-        $autolinkedDescription = hashtag_autolink($rawDescription, $index);
-
-        $this->assertContains($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('ашок', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
-    }
-
-    /**
-     * Test hashtags auto-link without index URL.
-     */
-    public function testHashtagAutolinkNoIndex()
-    {
-        $rawDescription = 'blabla #hashtag x#nothashtag';
-        $autolinkedDescription = hashtag_autolink($rawDescription);
-
-        $this->assertContains($this->getHashtagLink('hashtag'), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
-    }
-
-    /**
-     * Util function to build an hashtag link.
-     *
-     * @param string $hashtag Hashtag name.
-     * @param string $index   Index URL.
-     *
-     * @return string HTML hashtag link.
-     */
-    private function getHashtagLink($hashtag, $index = '')
-    {
-        $hashtagLink = '#$1';
-        return str_replace('$1', $hashtag, $hashtagLink);
-    }
-}
-
-// old style mock: PHPUnit doesn't allow function mock
-
-/**
- * Returns code 200 or html content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/html'
- */
-function ut_curl_getinfo_ok($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html; charset=utf-8';
-    }
-}
-
-/**
- * Returns code 200 or html content type without charset.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/html'
- */
-function ut_curl_getinfo_no_charset($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html';
-    }
-}
-
-/**
- * Invalid response code.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 404 or 'text/html'
- */
-function ut_curl_getinfo_rc_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 404;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html; charset=utf-8';
-    }
-}
-
-/**
- * Invalid content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/plain'
- */
-function ut_curl_getinfo_ct_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/plain';
-    }
-}
-
-/**
- * Invalid response code and content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 404 or 'text/plain'
- */
-function ut_curl_getinfo_rs_ct_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 404;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/plain';
-    }
-}
diff --git a/tests/bookmark/LinkUtilsTest.php b/tests/bookmark/LinkUtilsTest.php
new file mode 100644
index 00000000..1b8688e6
--- /dev/null
+++ b/tests/bookmark/LinkUtilsTest.php
@@ -0,0 +1,333 @@
+stuff' . $title . '';
+        $this->assertEquals($title, html_extract_title($html));
+        $html = '' . $title . 'blablaanother';
+        $this->assertEquals($title, html_extract_title($html));
+    }
+
+    /**
+     * Test html_extract_title() when the title is not found.
+     */
+    public function testHtmlExtractNonExistentTitle()
+    {
+        $html = 'stuff';
+        $this->assertFalse(html_extract_title($html));
+    }
+
+    /**
+     * Test headers_extract_charset() when the charset is found.
+     */
+    public function testHeadersExtractExistentCharset()
+    {
+        $charset = 'x-MacCroatian';
+        $headers = 'text/html; charset=' . $charset;
+        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
+    }
+
+    /**
+     * Test headers_extract_charset() when the charset is not found.
+     */
+    public function testHeadersExtractNonExistentCharset()
+    {
+        $headers = '';
+        $this->assertFalse(header_extract_charset($headers));
+
+        $headers = 'text/html';
+        $this->assertFalse(header_extract_charset($headers));
+    }
+
+    /**
+     * Test html_extract_charset() when the charset is found.
+     */
+    public function testHtmlExtractExistentCharset()
+    {
+        $charset = 'x-MacCroatian';
+        $html = 'stuff2';
+        $this->assertEquals(strtolower($charset), html_extract_charset($html));
+    }
+
+    /**
+     * Test html_extract_charset() when the charset is not found.
+     */
+    public function testHtmlExtractNonExistentCharset()
+    {
+        $html = 'stuff';
+        $this->assertFalse(html_extract_charset($html));
+        $html = 'stuff';
+        $this->assertFalse(html_extract_charset($html));
+    }
+
+    /**
+     * Test the download callback with valid value
+     */
+    public function testCurlDownloadCallbackOk()
+    {
+        $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
+        $data = [
+            'HTTP/1.1 200 OK',
+            'Server: GitHub.com',
+            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
+            'Content-Type: text/html; charset=utf-8',
+            'Status: 200 OK',
+            'end' => 'th=device-width">'
+                . 'Refactoring · GitHub'
+                . ''
+                . 'Refactoring · GitHub'
+                . '',
+            'end' => 'th=device-width">'
+                . 'Refactoring · GitHub'
+                . 'Refactoring · GitHub'
+            . 'http://hello.there/is=someone#here otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+
+        $text = 'stuff http://hello.there/is=someone#here(please) otherstuff';
+        $expectedText = 'stuff '
+            . 'http://hello.there/is=someone#here(please) otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+
+        $text = 'stuff http://hello.there/is=someone#here(please)&no otherstuff';
+        $expectedText = 'stuff '
+            . 'http://hello.there/is=someone#here(please)&no otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test text2clickable with a redirector set.
+     */
+    public function testText2clickableWithRedirector()
+    {
+        $text = 'stuff http://hello.there/is=someone#here otherstuff';
+        $redirector = 'http://redirector.to';
+        $expectedText = 'stuff http://hello.there/is=someone#here otherstuff';
+        $processedText = text2clickable($text, $redirector);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test text2clickable a redirector set and without URL encode.
+     */
+    public function testText2clickableWithRedirectorDontEncode()
+    {
+        $text = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
+        $redirector = 'http://redirector.to';
+        $expectedText = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
+        $processedText = text2clickable($text, $redirector, false);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test testSpace2nbsp.
+     */
+    public function testSpace2nbsp()
+    {
+        $text = '  Are you   thrilled  by flags   ?' . PHP_EOL . ' Really?';
+        $expectedText = '  Are you   thrilled  by flags   ?' . PHP_EOL . ' Really?';
+        $processedText = space2nbsp($text);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test hashtags auto-link.
+     */
+    public function testHashtagAutolink()
+    {
+        $index = 'http://domain.tld/';
+        $rawDescription = '#hashtag\n
+            # nothashtag\n
+            test#nothashtag #hashtag \#nothashtag\n
+            test #hashtag #hashtag test #hashtag.test\n
+            #hashtag #hashtag-nothashtag #hashtag_hashtag\n
+            What is #ашок anyway?\n
+            カタカナ #カタカナ」カタカナ\n';
+        $autolinkedDescription = hashtag_autolink($rawDescription, $index);
+
+        $this->assertContains($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
+        $this->assertNotContains(' #hashtag', $autolinkedDescription);
+        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('ашок', $index), $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
+        $this->assertNotContains($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
+    }
+
+    /**
+     * Test hashtags auto-link without index URL.
+     */
+    public function testHashtagAutolinkNoIndex()
+    {
+        $rawDescription = 'blabla #hashtag x#nothashtag';
+        $autolinkedDescription = hashtag_autolink($rawDescription);
+
+        $this->assertContains($this->getHashtagLink('hashtag'), $autolinkedDescription);
+        $this->assertNotContains(' #hashtag', $autolinkedDescription);
+        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
+    }
+
+    /**
+     * Util function to build an hashtag link.
+     *
+     * @param string $hashtag Hashtag name.
+     * @param string $index Index URL.
+     *
+     * @return string HTML hashtag link.
+     */
+    private function getHashtagLink($hashtag, $index = '')
+    {
+        $hashtagLink = '#$1';
+        return str_replace('$1', $hashtag, $hashtagLink);
+    }
+}
diff --git a/tests/plugins/PluginMarkdownTest.php b/tests/plugins/PluginMarkdownTest.php
index 44364b05..d6951866 100644
--- a/tests/plugins/PluginMarkdownTest.php
+++ b/tests/plugins/PluginMarkdownTest.php
@@ -5,6 +5,7 @@ use Shaarli\Config\ConfigManager;
  * PluginMarkdownTest.php
  */
 
+require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/Utils.php';
 require_once 'plugins/markdown/markdown.php';
 
diff --git a/tests/utils/CurlUtils.php b/tests/utils/CurlUtils.php
new file mode 100644
index 00000000..1cc4907e
--- /dev/null
+++ b/tests/utils/CurlUtils.php
@@ -0,0 +1,94 @@
+