]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
LinkDB: move to a proper file, add test coverage 218/head
authorVirtualTam <virtualtam@flibidi.org>
Wed, 11 Mar 2015 23:43:02 +0000 (00:43 +0100)
committerVirtualTam <virtualtam@flibidi.net>
Wed, 10 Jun 2015 22:45:45 +0000 (00:45 +0200)
Relates to #71

LinkDB
 - move to application/LinkDB.php
 - code cleanup
   - indentation
   - whitespaces
   - formatting
 - comment cleanup
   - add missing documentation
   - unify formatting

Test coverage for LinkDB
 - constructor
 - public / private access
 - link-related methods

Shaarli utilities (LinkDB dependencies)
 - move startsWith() and endsWith() functions to application/Utils.php
 - add test coverage

Dev utilities
 - Composer: add PHPUnit to dev dependencies
 - Makefile:
    - update lint targets
    - add test targets
    - generate coverage reports

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
12 files changed:
.gitignore
Makefile
application/.htaccess [new file with mode: 0644]
application/LinkDB.php [new file with mode: 0644]
application/Utils.php [new file with mode: 0644]
composer.json
index.php
phpunit.xml [new file with mode: 0644]
tests/.htaccess [new file with mode: 0644]
tests/LinkDBTest.php [new file with mode: 0644]
tests/UtilsTest.php [new file with mode: 0644]
tests/utils/ReferenceLinkDB.php [new file with mode: 0644]

index 33d8a4884f2a82f2801e5671d06045f500ad16b9..6fd0ccd80c189ad1f6aec6e412878854ca439fd8 100644 (file)
@@ -16,5 +16,7 @@ pagecache
 composer.lock
 /vendor/
 
-# Ignore test output
+# Ignore test data & output
+coverage
+tests/datastore.php
 phpmd.html
index e6f428531f16f7511e5b0b9a9e32489c0718b1c2..80efcfaafd0e2973c4ea73aaf0c33e3ff77a0ca0 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -8,12 +8,15 @@
 # - install/update test dependencies:
 #   $ composer install  # 1st setup
 #   $ composer update
+# - install Xdebug for PHPUnit code coverage reports:
+#   - see http://xdebug.org/docs/install
+#   - enable in php.ini
 
 BIN = vendor/bin
-PHP_SOURCE = index.php
-MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
+PHP_SOURCE = index.php application tests
+PHP_COMMA_SOURCE = index.php,application,tests
 
-all: static_analysis_summary
+all: static_analysis_summary test
 
 ##
 # Concise status of the project
@@ -21,6 +24,7 @@ all: static_analysis_summary
 ##
 
 static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
+       @echo
 
 ##
 # PHP_CodeSniffer
@@ -62,6 +66,7 @@ copy_paste:
 # Detects PHP syntax errors, sorted by category
 # Rules documentation: http://phpmd.org/rules/index.html
 ##
+MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
 
 mess_title:
        @echo "-----------------"
@@ -70,11 +75,11 @@ mess_title:
 
 ###  - all warnings
 mess_detector: mess_title
-       @$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
+       @$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
 
 ### - all warnings + HTML output contains links to PHPMD's documentation
 mess_detector_html:
-       @$(BIN)/phpmd $(PHP_SOURCE) html $(MESS_DETECTOR_RULES) \
+       @$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
        --reportfile phpmd.html || exit 0
 
 ### - warnings grouped by message, sorted by descending frequency order
@@ -85,10 +90,24 @@ mess_detector_grouped: mess_title
 ### - summary: number of warnings by rule set
 mess_detector_summary: mess_title
        @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
-               warnings=$$($(BIN)/phpmd $(PHP_SOURCE) text $$rule | wc -l); \
+               warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
                printf "$$warnings\t$$rule\n"; \
        done;
 
+##
+# PHPUnit
+# Runs unitary and functional tests
+# Generates an HTML coverage report if Xdebug is enabled
+#
+# See phpunit.xml for configuration
+# https://phpunit.de/manual/current/en/appendixes.configuration.html
+##
+test: clean
+       @echo "-------"
+       @echo "PHPUNIT"
+       @echo "-------"
+       @$(BIN)/phpunit tests
+
 ##
 # Targets for repository and documentation maintenance
 ##
@@ -107,4 +126,4 @@ doc: clean
 htmldoc:
        for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
                pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \
-       done;
\ No newline at end of file
+       done;
diff --git a/application/.htaccess b/application/.htaccess
new file mode 100644 (file)
index 0000000..b584d98
--- /dev/null
@@ -0,0 +1,2 @@
+Allow from none
+Deny from all
diff --git a/application/LinkDB.php b/application/LinkDB.php
new file mode 100644 (file)
index 0000000..388002f
--- /dev/null
@@ -0,0 +1,412 @@
+<?php
+/**
+ * Data storage for links.
+ *
+ * This object behaves like an associative array.
+ *
+ * Example:
+ *    $myLinks = new LinkDB();
+ *    echo $myLinks['20110826_161819']['title'];
+ *    foreach ($myLinks as $link)
+ *       echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
+ *
+ * Available keys:
+ *  - description: description of the entry
+ *  - linkdate: date of the creation of this entry, in the form YYYYMMDD_HHMMSS
+ *              (e.g.'20110914_192317')
+ *  - private:  Is this link private? 0=no, other value=yes
+ *  - tags:     tags attached to this entry (separated by spaces)
+ *  - title     Title of the link
+ *  - url       URL of the link. Can be absolute or relative.
+ *              Relative URLs are permalinks (e.g.'?m-ukcw')
+ *
+ * Implements 3 interfaces:
+ *  - ArrayAccess: behaves like an associative array;
+ *  - Countable:   there is a count() method;
+ *  - Iterator:    usable in foreach () loops.
+ */
+class LinkDB implements Iterator, Countable, ArrayAccess
+{
+    // List of links (associative array)
+    //  - key:   link date (e.g. "20110823_124546"),
+    //  - value: associative array (keys: title, description...)
+    private $links;
+
+    // List of all recorded URLs (key=url, value=linkdate)
+    // for fast reserve search (url-->linkdate)
+    private $urls;
+
+    // List of linkdate keys (for the Iterator interface implementation)
+    private $keys;
+
+    // Position in the $this->keys array (for the Iterator interface)
+    private $position;
+
+    // Is the user logged in? (used to filter private links)
+    private $loggedIn;
+
+    /**
+     * Creates a new LinkDB
+     *
+     * Checks if the datastore exists; else, attempts to create a dummy one.
+     *
+     * @param $isLoggedIn is the user logged in?
+     */
+    function __construct($isLoggedIn)
+    {
+        // FIXME: do not access $GLOBALS, pass the datastore instead
+        $this->loggedIn = $isLoggedIn;
+        $this->checkDB();
+        $this->readdb();
+    }
+
+    /**
+     * Countable - Counts elements of an object
+     */
+    public function count()
+    {
+        return count($this->links);
+    }
+
+    /**
+     * ArrayAccess - Assigns a value to the specified offset
+     */
+    public function offsetSet($offset, $value)
+    {
+        // TODO: use exceptions instead of "die"
+        if (!$this->loggedIn) {
+            die('You are not authorized to add a link.');
+        }
+        if (empty($value['linkdate']) || empty($value['url'])) {
+            die('Internal Error: A link should always have a linkdate and URL.');
+        }
+        if (empty($offset)) {
+            die('You must specify a key.');
+        }
+        $this->links[$offset] = $value;
+        $this->urls[$value['url']]=$offset;
+    }
+
+    /**
+     * ArrayAccess - Whether or not an offset exists
+     */
+    public function offsetExists($offset)
+    {
+        return array_key_exists($offset, $this->links);
+    }
+
+    /**
+     * ArrayAccess - Unsets an offset
+     */
+    public function offsetUnset($offset)
+    {
+        if (!$this->loggedIn) {
+            // TODO: raise an exception
+            die('You are not authorized to delete a link.');
+        }
+        $url = $this->links[$offset]['url'];
+        unset($this->urls[$url]);
+        unset($this->links[$offset]);
+    }
+
+    /**
+     * ArrayAccess - Returns the value at specified offset
+     */
+    public function offsetGet($offset)
+    {
+        return isset($this->links[$offset]) ? $this->links[$offset] : null;
+    }
+
+    /**
+     * Iterator - Returns the current element
+     */
+    function current()
+    {
+        return $this->links[$this->keys[$this->position]];
+    }
+
+    /**
+     * Iterator - Returns the key of the current element
+     */
+    function key()
+    {
+        return $this->keys[$this->position];
+    }
+
+    /**
+     * Iterator - Moves forward to next element
+     */
+    function next()
+    {
+        ++$this->position;
+    }
+
+    /**
+     * Iterator - Rewinds the Iterator to the first element
+     *
+     * Entries are sorted by date (latest first)
+     */
+    function rewind()
+    {
+        $this->keys = array_keys($this->links);
+        rsort($this->keys);
+        $this->position = 0;
+    }
+
+    /**
+     * Iterator - Checks if current position is valid
+     */
+    function valid()
+    {
+        return isset($this->keys[$this->position]);
+    }
+
+    /**
+     * Checks if the DB directory and file exist
+     *
+     * If no DB file is found, creates a dummy DB.
+     */
+    private function checkDB()
+    {
+        if (file_exists($GLOBALS['config']['DATASTORE'])) {
+            return;
+        }
+
+        // Create a dummy database for example
+        $this->links = array();
+        $link = array(
+            'title'=>'Shaarli - sebsauvage.net',
+            'url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli',
+            'description'=>'Welcome to Shaarli! This is a bookmark. To edit or delete me, you must first login.',
+            'private'=>0,
+            'linkdate'=>'20110914_190000',
+            'tags'=>'opensource software'
+        );
+        $this->links[$link['linkdate']] = $link;
+
+        $link = array(
+            'title'=>'My secret stuff... - Pastebin.com',
+            'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
+            'description'=>'SShhhh!!  I\'m a private link only YOU can see. You can delete me too.',
+            'private'=>1,
+            'linkdate'=>'20110914_074522',
+            'tags'=>'secretstuff'
+        );
+        $this->links[$link['linkdate']] = $link;
+
+        // Write database to disk
+        // TODO: raise an exception if the file is not write-able
+        file_put_contents(
+            // FIXME: do not use $GLOBALS
+            $GLOBALS['config']['DATASTORE'],
+            PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
+        );
+    }
+
+    /**
+     * Reads database from disk to memory
+     */
+    private function readdb()
+    {
+        // Read data
+        // Note that gzinflate is faster than gzuncompress.
+        // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
+        // FIXME: do not use $GLOBALS
+        $this->links = array();
+
+        if (file_exists($GLOBALS['config']['DATASTORE'])) {
+            $this->links = unserialize(gzinflate(base64_decode(
+                substr(file_get_contents($GLOBALS['config']['DATASTORE']),
+                       strlen(PHPPREFIX), -strlen(PHPSUFFIX)))));
+        }
+
+        // If user is not logged in, filter private links.
+        if (!$this->loggedIn) {
+            $toremove = array();
+            foreach ($this->links as $link) {
+                if ($link['private'] != 0) {
+                    $toremove[] = $link['linkdate'];
+                }
+            }
+            foreach ($toremove as $linkdate) {
+                unset($this->links[$linkdate]);
+            }
+        }
+
+        // Keep the list of the mapping URLs-->linkdate up-to-date.
+        $this->urls = array();
+        foreach ($this->links as $link) {
+            $this->urls[$link['url']] = $link['linkdate'];
+        }
+    }
+
+    /**
+     * Saves the database from memory to disk
+     */
+    public function savedb()
+    {
+        if (!$this->loggedIn) {
+            // TODO: raise an Exception instead
+            die('You are not authorized to change the database.');
+        }
+        file_put_contents(
+            $GLOBALS['config']['DATASTORE'],
+            PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
+        );
+        invalidateCaches();
+    }
+
+    /**
+     * Returns the link for a given URL, or False if it does not exist.
+     */
+    public function getLinkFromUrl($url)
+    {
+        if (isset($this->urls[$url])) {
+            return $this->links[$this->urls[$url]];
+        }
+        return false;
+    }
+
+    /**
+     * Returns the list of links corresponding to a full-text search
+     *
+     * Searches:
+     *  - in the URLs, title and description;
+     *  - are case-insensitive.
+     *
+     * Example:
+     *    print_r($mydb->filterFulltext('hollandais'));
+     *
+     * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
+     *  - allows to perform searches on Unicode text
+     *  - see https://github.com/shaarli/Shaarli/issues/75 for examples
+     */
+    public function filterFulltext($searchterms)
+    {
+        // FIXME: explode(' ',$searchterms) and perform a AND search.
+        // FIXME: accept double-quotes to search for a string "as is"?
+        $filtered = array();
+        $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
+        $keys = ['title', 'description', 'url', 'tags'];
+
+        foreach ($this->links as $link) {
+            $found = false;
+
+            foreach ($keys as $key) {
+                if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
+                           $search) !== false) {
+                    $found = true;
+                }
+            }
+
+            if ($found) {
+                $filtered[$link['linkdate']] = $link;
+            }
+        }
+        krsort($filtered);
+        return $filtered;
+    }
+
+    /**
+     * Returns the list of links associated with a given list of tags
+     *
+     * You can specify one or more tags, separated by space or a comma, e.g.
+     *  print_r($mydb->filterTags('linux programming'));
+     */
+    public function filterTags($tags, $casesensitive=false)
+    {
+        // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
+        // FIXME: is $casesensitive ever true?
+        $t = str_replace(
+            ',', ' ',
+            ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))
+        );
+
+        $searchtags = explode(' ', $t);
+        $filtered = array();
+
+        foreach ($this->links as $l) {
+            $linktags = explode(
+                ' ',
+                ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
+            );
+
+            if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
+                $filtered[$l['linkdate']] = $l;
+            }
+        }
+        krsort($filtered);
+        return $filtered;
+    }
+
+
+    /**
+     * Returns the list of articles for a given day, chronologically sorted
+     *
+     * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
+     *  print_r($mydb->filterDay('20120125'));
+     */
+    public function filterDay($day)
+    {
+        // TODO: check input format
+        $filtered = array();
+        foreach ($this->links as $l) {
+            if (startsWith($l['linkdate'], $day)) {
+                $filtered[$l['linkdate']] = $l;
+            }
+        }
+        ksort($filtered);
+        return $filtered;
+    }
+
+    /**
+     * Returns the article corresponding to a smallHash
+     */
+    public function filterSmallHash($smallHash)
+    {
+        $filtered = array();
+        foreach ($this->links as $l) {
+            if ($smallHash == smallHash($l['linkdate'])) {
+                // Yes, this is ugly and slow
+                $filtered[$l['linkdate']] = $l;
+                return $filtered;
+            }
+        }
+        return $filtered;
+    }
+
+    /**
+     * Returns the list of all tags
+     * Output: associative array key=tags, value=0
+     */
+    public function allTags()
+    {
+        $tags = array();
+        foreach ($this->links as $link) {
+            foreach (explode(' ', $link['tags']) as $tag) {
+                if (!empty($tag)) {
+                    $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1);
+                }
+            }
+        }
+        // Sort tags by usage (most used tag first)
+        arsort($tags);
+        return $tags;
+    }
+
+    /**
+     * Returns the list of days containing articles (oldest first)
+     * Output: An array containing days (in format YYYYMMDD).
+     */
+    public function days()
+    {
+        $linkDays = array();
+        foreach (array_keys($this->links) as $day) {
+            $linkDays[substr($day, 0, 8)] = 0;
+        }
+        $linkDays = array_keys($linkDays);
+        sort($linkDays);
+        return $linkDays;
+    }
+}
+?>
diff --git a/application/Utils.php b/application/Utils.php
new file mode 100644 (file)
index 0000000..737f150
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Shaarli utilities
+ */
+
+/**
+ *  Returns the small hash of a string, using RFC 4648 base64url format
+ *
+ *  Small hashes:
+ *   - are unique (well, as unique as crc32, at last)
+ *   - are always 6 characters long.
+ *   - only use the following characters: a-z A-Z 0-9 - _ @
+ *   - are NOT cryptographically secure (they CAN be forged)
+ *
+ *  In Shaarli, they are used as a tinyurl-like link to individual entries,
+ *  e.g. smallHash('20111006_131924') --> yZH23w
+ */
+function smallHash($text)
+{
+    $t = rtrim(base64_encode(hash('crc32', $text, true)), '=');
+    return strtr($t, '+/', '-_');
+}
+
+/**
+ * Tells if a string start with a substring
+ */
+function startsWith($haystack, $needle, $case=true)
+{
+    if ($case) {
+        return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
+    }
+    return (strcasecmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
+}
+
+/**
+ * Tells if a string ends with a substring
+ */
+function endsWith($haystack, $needle, $case=true)
+{
+    if ($case) {
+        return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
+    }
+    return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
+}
+?>
index d1f613c166984b53c42f5ef91982bec5bfd2bf6e..f6d92c929494c5ae50ca3a6f2dd050e2ffdfb399 100644 (file)
@@ -8,6 +8,7 @@
     "require": {},
     "require-dev": {
         "phpmd/phpmd" : "@stable",
+        "phpunit/phpunit": "4.6.*",
         "sebastian/phpcpd": "*",
         "squizlabs/php_codesniffer": "2.*"
     }
index 9561f63b63a3975b897ab894334dfa1025168f7f..ed18c7f948c9de773c7bd90150e804371afa30ba 100644 (file)
--- a/index.php
+++ b/index.php
@@ -68,6 +68,10 @@ checkphpversion();
 error_reporting(E_ALL^E_WARNING);  // See all error except warnings.
 //error_reporting(-1); // See all errors (for debugging only)
 
+// Shaarli library
+require_once 'application/LinkDB.php';
+require_once 'application/Utils.php';
+
 include "inc/rain.tpl.class.php"; //include Rain TPL
 raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
 raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory
@@ -268,21 +272,6 @@ function nl2br_escaped($html)
     return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html)));
 }
 
-/* Returns the small hash of a string, using RFC 4648 base64url format
-   e.g. smallHash('20111006_131924') --> yZH23w
-   Small hashes:
-     - are unique (well, as unique as crc32, at last)
-     - are always 6 characters long.
-     - only use the following characters: a-z A-Z 0-9 - _ @
-     - are NOT cryptographically secure (they CAN be forged)
-   In Shaarli, they are used as a tinyurl-like link to individual entries.
-*/
-function smallHash($text)
-{
-    $t = rtrim(base64_encode(hash('crc32',$text,true)),'=');
-    return strtr($t, '+/', '-_');
-}
-
 // In a string, converts URLs to clickable links.
 // Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
 function text2clickable($url)
@@ -536,20 +525,6 @@ function getMaxFileSize()
     return $maxsize;
 }
 
-// Tells if a string start with a substring or not.
-function startsWith($haystack,$needle,$case=true)
-{
-    if($case){return (strcmp(substr($haystack, 0, strlen($needle)),$needle)===0);}
-    return (strcasecmp(substr($haystack, 0, strlen($needle)),$needle)===0);
-}
-
-// Tells if a string ends with a substring or not.
-function endsWith($haystack,$needle,$case=true)
-{
-    if($case){return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);}
-    return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);
-}
-
 /*  Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch)
     (used to build the ADD_DATE attribute in Netscape-bookmarks file)
     PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */
@@ -710,220 +685,6 @@ class pageBuilder
     }
 }
 
-// ------------------------------------------------------------------------------------------
-/* Data storage for links.
-   This object behaves like an associative array.
-   Example:
-      $mylinks = new linkdb();
-      echo $mylinks['20110826_161819']['title'];
-      foreach($mylinks as $link)
-         echo $link['title'].' at url '.$link['url'].' ; description:'.$link['description'];
-
-   Available keys:
-       title : Title of the link
-       url : URL of the link. Can be absolute or relative. Relative URLs are permalinks (e.g.'?m-ukcw')
-       description : description of the entry
-       private : Is this link private? 0=no, other value=yes
-       linkdate : date of the creation of this entry, in the form YYYYMMDD_HHMMSS (e.g.'20110914_192317')
-       tags : tags attached to this entry (separated by spaces)
-
-   We implement 3 interfaces:
-     - ArrayAccess so that this object behaves like an associative array.
-     - Iterator so that this object can be used in foreach() loops.
-     - Countable interface so that we can do a count() on this object.
-*/
-class linkdb implements Iterator, Countable, ArrayAccess
-{
-    private $links; // List of links (associative array. Key=linkdate (e.g. "20110823_124546"), value= associative array (keys:title,description...)
-    private $urls;  // List of all recorded URLs (key=url, value=linkdate) for fast reserve search (url-->linkdate)
-    private $keys;  // List of linkdate keys (for the Iterator interface implementation)
-    private $position; // Position in the $this->keys array. (for the Iterator interface implementation.)
-    private $loggedin; // Is the user logged in? (used to filter private links)
-
-    // Constructor:
-    function __construct($isLoggedIn)
-    // Input : $isLoggedIn : is the user logged in?
-    {
-        $this->loggedin = $isLoggedIn;
-        $this->checkdb(); // Make sure data file exists.
-        $this->readdb();  // Then read it.
-    }
-
-    // ---- Countable interface implementation
-    public function count() { return count($this->links); }
-
-    // ---- ArrayAccess interface implementation
-    public function offsetSet($offset, $value)
-    {
-        if (!$this->loggedin) die('You are not authorized to add a link.');
-        if (empty($value['linkdate']) || empty($value['url'])) die('Internal Error: A link should always have a linkdate and URL.');
-        if (empty($offset)) die('You must specify a key.');
-        $this->links[$offset] = $value;
-        $this->urls[$value['url']]=$offset;
-    }
-    public function offsetExists($offset) { return array_key_exists($offset,$this->links); }
-    public function offsetUnset($offset)
-    {
-        if (!$this->loggedin) die('You are not authorized to delete a link.');
-        $url = $this->links[$offset]['url']; unset($this->urls[$url]);
-        unset($this->links[$offset]);
-    }
-    public function offsetGet($offset) { return isset($this->links[$offset]) ? $this->links[$offset] : null; }
-
-    // ---- Iterator interface implementation
-    function rewind() { $this->keys=array_keys($this->links); rsort($this->keys); $this->position=0; } // Start over for iteration, ordered by date (latest first).
-    function key() { return $this->keys[$this->position]; } // current key
-    function current() { return $this->links[$this->keys[$this->position]]; } // current value
-    function next() { ++$this->position; } // go to next item
-    function valid() { return isset($this->keys[$this->position]); }    // Check if current position is valid.
-
-    // ---- Misc methods
-    private function checkdb() // Check if db directory and file exists.
-    {
-        if (!file_exists($GLOBALS['config']['DATASTORE'])) // Create a dummy database for example.
-        {
-             $this->links = array();
-             $link = array('title'=>'Shaarli - sebsauvage.net','url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli','description'=>'Welcome to Shaarli ! This is a bookmark. To edit or delete me, you must first login.','private'=>0,'linkdate'=>'20110914_190000','tags'=>'opensource software');
-             $this->links[$link['linkdate']] = $link;
-             $link = array('title'=>'My secret stuff... - Pastebin.com','url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=','description'=>'SShhhh!!  I\'m a private link only YOU can see. You can delete me too.','private'=>1,'linkdate'=>'20110914_074522','tags'=>'secretstuff');
-             $this->links[$link['linkdate']] = $link;
-             file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); // Write database to disk
-        }
-    }
-
-    // Read database from disk to memory
-    private function readdb()
-    {
-        // Read data
-        $this->links=(file_exists($GLOBALS['config']['DATASTORE']) ? unserialize(gzinflate(base64_decode(substr(file_get_contents($GLOBALS['config']['DATASTORE']),strlen(PHPPREFIX),-strlen(PHPSUFFIX))))) : array() );
-        // Note that gzinflate is faster than gzuncompress. See: http://www.php.net/manual/en/function.gzdeflate.php#96439
-
-        // If user is not logged in, filter private links.
-        if (!$this->loggedin)
-        {
-            $toremove=array();
-            foreach($this->links as $link) { if ($link['private']!=0) $toremove[]=$link['linkdate']; }
-            foreach($toremove as $linkdate) { unset($this->links[$linkdate]); }
-        }
-
-        // Keep the list of the mapping URLs-->linkdate up-to-date.
-        $this->urls=array();
-        foreach($this->links as $link) { $this->urls[$link['url']]=$link['linkdate']; }
-    }
-
-    // Save database from memory to disk.
-    public function savedb()
-    {
-        if (!$this->loggedin) die('You are not authorized to change the database.');
-        file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX);
-        invalidateCaches();
-    }
-
-    // Returns the link for a given URL (if it exists). False if it does not exist.
-    public function getLinkFromUrl($url)
-    {
-        if (isset($this->urls[$url])) return $this->links[$this->urls[$url]];
-        return false;
-    }
-
-    // Case insensitive search among links (in the URLs, title and description). Returns filtered list of links.
-    // e.g. print_r($mydb->filterFulltext('hollandais'));
-    public function filterFulltext($searchterms)
-    {
-        // FIXME: explode(' ',$searchterms) and perform a AND search.
-        // FIXME: accept double-quotes to search for a string "as is"?
-        // Using mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') allows us to perform searches on
-        // Unicode text. See https://github.com/shaarli/Shaarli/issues/75 for examples.
-        $filtered=array();
-        $s = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
-        foreach($this->links as $l)
-        {
-            $found=   (strpos(mb_convert_case($l['title'],       MB_CASE_LOWER, 'UTF-8'),$s) !== false)
-                   || (strpos(mb_convert_case($l['description'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
-                   || (strpos(mb_convert_case($l['url'],         MB_CASE_LOWER, 'UTF-8'),$s) !== false)
-                   || (strpos(mb_convert_case($l['tags'],        MB_CASE_LOWER, 'UTF-8'),$s) !== false);
-            if ($found) $filtered[$l['linkdate']] = $l;
-        }
-        krsort($filtered);
-        return $filtered;
-    }
-
-    // Filter by tag.
-    // You can specify one or more tags (tags can be separated by space or comma).
-    // e.g. print_r($mydb->filterTags('linux programming'));
-    public function filterTags($tags,$casesensitive=false)
-    {
-        // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
-        // TODO: is $casesensitive ever true ?
-        $t = str_replace(',',' ',($casesensitive?$tags:mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8')));
-        $searchtags=explode(' ',$t);
-        $filtered=array();
-        foreach($this->links as $l)
-        {
-            $linktags = explode(' ',($casesensitive?$l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8')));
-            if (count(array_intersect($linktags,$searchtags)) == count($searchtags))
-                $filtered[$l['linkdate']] = $l;
-        }
-        krsort($filtered);
-        return $filtered;
-    }
-
-    // Filter by day. Day must be in the form 'YYYYMMDD' (e.g. '20120125')
-    // Sort order is: older articles first.
-    // e.g. print_r($mydb->filterDay('20120125'));
-    public function filterDay($day)
-    {
-        $filtered=array();
-        foreach($this->links as $l)
-        {
-            if (startsWith($l['linkdate'],$day)) $filtered[$l['linkdate']] = $l;
-        }
-        ksort($filtered);
-        return $filtered;
-    }
-    // Filter by smallHash.
-    // Only 1 article is returned.
-    public function filterSmallHash($smallHash)
-    {
-        $filtered=array();
-        foreach($this->links as $l)
-        {
-            if ($smallHash==smallHash($l['linkdate'])) // Yes, this is ugly and slow
-            {
-                $filtered[$l['linkdate']] = $l;
-                return $filtered;
-            }
-        }
-        return $filtered;
-    }
-
-    // Returns the list of all tags
-    // Output: associative array key=tags, value=0
-    public function allTags()
-    {
-        $tags=array();
-        foreach($this->links as $link)
-            foreach(explode(' ',$link['tags']) as $tag)
-                if (!empty($tag)) $tags[$tag]=(empty($tags[$tag]) ? 1 : $tags[$tag]+1);
-        arsort($tags); // Sort tags by usage (most used tag first)
-        return $tags;
-    }
-
-    // Returns the list of days containing articles (oldest first)
-    // Output: An array containing days (in format YYYYMMDD).
-    public function days()
-    {
-        $linkdays=array();
-        foreach(array_keys($this->links) as $day)
-        {
-            $linkdays[substr($day,0,8)]=0;
-        }
-        $linkdays=array_keys($linkdays);
-        sort($linkdays);
-        return $linkdays;
-    }
-}
-
 // ------------------------------------------------------------------------------------------
 // Output the last N links in RSS 2.0 format.
 function showRSS()
@@ -941,7 +702,7 @@ function showRSS()
     $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
 
     // If cached was not found (or not usable), then read the database and build the response:
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if user it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
 
     // Optionally filter the results:
     $linksToDisplay=array();
@@ -1019,7 +780,7 @@ function showATOM()
     $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
     // If cached was not found (or not usable), then read the database and build the response:
 
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if used it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
 
 
     // Optionally filter the results:
@@ -1104,7 +865,7 @@ function showDailyRSS()
     $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn());
     $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
     // If cached was not found (or not usable), then read the database and build the response:
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if used it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
 
     /* Some Shaarlies may have very few links, so we need to look
        back in time (rsort()) until we have enough days ($nb_of_days).
@@ -1172,7 +933,7 @@ function showDailyRSS()
 // "Daily" page.
 function showDaily()
 {
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if used it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
 
 
     $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD.
@@ -1240,7 +1001,7 @@ function showDaily()
 // Render HTML page (according to URL parameters and user rights)
 function renderPage()
 {
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if used it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
 
     // -------- Display login form.
     if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login'))
@@ -1822,7 +1583,7 @@ HTML;
 function importFile()
 {
     if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); }
-    $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);  // Read links from database (and filter private links if used it not logged in).
+    $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
     $filename=$_FILES['filetoupload']['name'];
     $filesize=$_FILES['filetoupload']['size'];
     $data=file_get_contents($_FILES['filetoupload']['tmp_name']);
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644 (file)
index 0000000..d6e01c3
--- /dev/null
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.5/phpunit.xsd"
+    colors="true">
+  <filter>
+    <whitelist addUncoveredFilesFromWhitelist="true">
+      <directory suffix=".php">application</directory>
+    </whitelist>
+  </filter>
+  <logging>
+    <log type="coverage-html" target="coverage" lowUpperBound="30" highLowerBound="80"/>
+    <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
+  </logging>
+</phpunit>
diff --git a/tests/.htaccess b/tests/.htaccess
new file mode 100644 (file)
index 0000000..b584d98
--- /dev/null
@@ -0,0 +1,2 @@
+Allow from none
+Deny from all
diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php
new file mode 100644 (file)
index 0000000..bbe4e02
--- /dev/null
@@ -0,0 +1,509 @@
+<?php
+/**
+ * Link datastore tests
+ */
+
+require_once 'application/LinkDB.php';
+require_once 'application/Utils.php';
+require_once 'tests/utils/ReferenceLinkDB.php';
+
+define('PHPPREFIX', '<?php /* ');
+define('PHPSUFFIX', ' */ ?>');
+
+
+/**
+ * Unitary tests for LinkDB
+ */
+class LinkDBTest extends PHPUnit_Framework_TestCase
+{
+    // datastore to test write operations
+    protected static $testDatastore = 'tests/datastore.php';
+    protected static $dummyDatastoreSHA1 = 'e3edea8ea7bb50be4bcb404df53fbb4546a7156e';
+    protected static $refDB = null;
+    protected static $publicLinkDB = null;
+    protected static $privateLinkDB = null;
+
+    /**
+     * Instantiates public and private LinkDBs with test data
+     *
+     * The reference datastore contains public and private links that
+     * will be used to test LinkDB's methods:
+     *  - access filtering (public/private),
+     *  - link searches:
+     *    - by day,
+     *    - by tag,
+     *    - by text,
+     *  - etc.
+     */
+    public static function setUpBeforeClass()
+    {
+        self::$refDB = new ReferenceLinkDB();
+        self::$refDB->write(self::$testDatastore, PHPPREFIX, PHPSUFFIX);
+
+        $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
+        self::$publicLinkDB = new LinkDB(false);
+        self::$privateLinkDB = new LinkDB(true);
+    }
+
+    /**
+     * Resets test data for each test
+     */
+    protected function setUp()
+    {
+        $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
+        if (file_exists(self::$testDatastore)) {
+            unlink(self::$testDatastore);
+        }
+    }
+
+    /**
+     * Allows to test LinkDB's private methods
+     *
+     * @see
+     *  https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html
+     *  http://stackoverflow.com/a/2798203
+     */
+    protected static function getMethod($name)
+    {
+        $class = new ReflectionClass('LinkDB');
+        $method = $class->getMethod($name);
+        $method->setAccessible(true);
+        return $method;
+    }
+
+    /**
+     * Instantiate LinkDB objects - logged in user
+     */
+    public function testConstructLoggedIn()
+    {
+        new LinkDB(true);
+        $this->assertFileExists(self::$testDatastore);
+    }
+
+    /**
+     * Instantiate LinkDB objects - logged out or public instance
+     */
+    public function testConstructLoggedOut()
+    {
+        new LinkDB(false);
+        $this->assertFileExists(self::$testDatastore);
+    }
+
+    /**
+     * Attempt to instantiate a LinkDB whereas the datastore is not writable
+     *
+     * @expectedException              PHPUnit_Framework_Error_Warning
+     * @expectedExceptionMessageRegExp /failed to open stream: No such file or directory/
+     */
+    public function testConstructDatastoreNotWriteable()
+    {
+        $GLOBALS['config']['DATASTORE'] = 'null/store.db';
+        new LinkDB(false);
+    }
+
+    /**
+     * The DB doesn't exist, ensure it is created with dummy content
+     */
+    public function testCheckDBNew()
+    {
+        $linkDB = new LinkDB(false);
+        unlink(self::$testDatastore);
+        $this->assertFileNotExists(self::$testDatastore);
+
+        $checkDB = self::getMethod('checkDB');
+        $checkDB->invokeArgs($linkDB, array());
+        $this->assertFileExists(self::$testDatastore);
+
+        // ensure the correct data has been written
+        $this->assertEquals(
+            self::$dummyDatastoreSHA1,
+            sha1_file(self::$testDatastore)
+        );
+    }
+
+    /**
+     * The DB exists, don't do anything
+     */
+    public function testCheckDBLoad()
+    {
+        $linkDB = new LinkDB(false);
+        $this->assertEquals(
+            self::$dummyDatastoreSHA1,
+            sha1_file(self::$testDatastore)
+        );
+
+        $checkDB = self::getMethod('checkDB');
+        $checkDB->invokeArgs($linkDB, array());
+
+        // ensure the datastore is left unmodified
+        $this->assertEquals(
+            self::$dummyDatastoreSHA1,
+            sha1_file(self::$testDatastore)
+        );
+    }
+
+    /**
+     * Load an empty DB
+     */
+    public function testReadEmptyDB()
+    {
+        file_put_contents(self::$testDatastore, PHPPREFIX.'S7QysKquBQA='.PHPSUFFIX);
+        $emptyDB = new LinkDB(false);
+        $this->assertEquals(0, sizeof($emptyDB));
+        $this->assertEquals(0, count($emptyDB));
+    }
+
+    /**
+     * Load public links from the DB
+     */
+    public function testReadPublicDB()
+    {
+        $this->assertEquals(
+            self::$refDB->countPublicLinks(),
+            sizeof(self::$publicLinkDB)
+        );
+    }
+
+    /**
+     * Load public and private links from the DB
+     */
+    public function testReadPrivateDB()
+    {
+        $this->assertEquals(
+            self::$refDB->countLinks(),
+            sizeof(self::$privateLinkDB)
+        );
+    }
+
+    /**
+     * Save the links to the DB
+     */
+    public function testSaveDB()
+    {
+        $testDB = new LinkDB(true);
+        $dbSize = sizeof($testDB);
+
+        $link = array(
+            'title'=>'an additional link',
+            'url'=>'http://dum.my',
+            'description'=>'One more',
+            'private'=>0,
+            'linkdate'=>'20150518_190000',
+            'tags'=>'unit test'
+        );
+        $testDB[$link['linkdate']] = $link;
+
+        // TODO: move PageCache to a proper class/file
+        function invalidateCaches() {}
+
+        $testDB->savedb();
+
+        $testDB = new LinkDB(true);
+        $this->assertEquals($dbSize + 1, sizeof($testDB));
+    }
+
+    /**
+     * Count existing links
+     */
+    public function testCount()
+    {
+        $this->assertEquals(
+            self::$refDB->countPublicLinks(),
+            self::$publicLinkDB->count()
+        );
+        $this->assertEquals(
+            self::$refDB->countLinks(),
+            self::$privateLinkDB->count()
+        );
+    }
+
+    /**
+     * List the days for which links have been posted
+     */
+    public function testDays()
+    {
+        $this->assertEquals(
+            ['20121206', '20130614', '20150310'],
+            self::$publicLinkDB->days()
+        );
+
+        $this->assertEquals(
+            ['20121206', '20130614', '20141125', '20150310'],
+            self::$privateLinkDB->days()
+        );
+    }
+
+    /**
+     * The URL corresponds to an existing entry in the DB
+     */
+    public function testGetKnownLinkFromURL()
+    {
+        $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/');
+
+        $this->assertNotEquals(false, $link);
+        $this->assertEquals(
+            'A free software media publishing platform',
+            $link['description']
+        );
+    }
+
+    /**
+     * The URL is not in the DB
+     */
+    public function testGetUnknownLinkFromURL()
+    {
+        $this->assertEquals(
+            false,
+            self::$publicLinkDB->getLinkFromUrl('http://dev.null')
+        );
+    }
+
+    /**
+     * Lists all tags
+     */
+    public function testAllTags()
+    {
+        $this->assertEquals(
+            [
+                'web' => 3,
+                'cartoon' => 2,
+                'gnu' => 2,
+                'dev' => 1,
+                'samba' => 1,
+                'media' => 1,
+                'software' => 1,
+                'stallman' => 1,
+                'free' => 1
+            ],
+            self::$publicLinkDB->allTags()
+        );
+
+        $this->assertEquals(
+            [
+                'web' => 4,
+                'cartoon' => 3,
+                'gnu' => 2,
+                'dev' => 2,
+                'samba' => 1,
+                'media' => 1,
+                'software' => 1,
+                'stallman' => 1,
+                'free' => 1,
+                'html' => 1,
+                'w3c' => 1,
+                'css' => 1,
+                'Mercurial' => 1
+            ],
+            self::$privateLinkDB->allTags()
+        );
+    }
+
+    /**
+     * Filter links using a tag
+     */
+    public function testFilterOneTag()
+    {
+        $this->assertEquals(
+            3,
+            sizeof(self::$publicLinkDB->filterTags('web', false))
+        );
+
+        $this->assertEquals(
+            4,
+            sizeof(self::$privateLinkDB->filterTags('web', false))
+        );
+    }
+
+    /**
+     * Filter links using a tag - case-sensitive
+     */
+    public function testFilterCaseSensitiveTag()
+    {
+        $this->assertEquals(
+            0,
+            sizeof(self::$privateLinkDB->filterTags('mercurial', true))
+        );
+
+        $this->assertEquals(
+            1,
+            sizeof(self::$privateLinkDB->filterTags('Mercurial', true))
+        );
+    }
+
+    /**
+     * Filter links using a tag combination
+     */
+    public function testFilterMultipleTags()
+    {
+        $this->assertEquals(
+            1,
+            sizeof(self::$publicLinkDB->filterTags('dev cartoon', false))
+        );
+
+        $this->assertEquals(
+            2,
+            sizeof(self::$privateLinkDB->filterTags('dev cartoon', false))
+        );
+    }
+
+    /**
+     * Filter links using a non-existent tag
+     */
+    public function testFilterUnknownTag()
+    {
+        $this->assertEquals(
+            0,
+            sizeof(self::$publicLinkDB->filterTags('null', false))
+        );
+    }
+
+    /**
+     * Return links for a given day
+     */
+    public function testFilterDay()
+    {
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterDay('20121206'))
+        );
+
+        $this->assertEquals(
+            3,
+            sizeof(self::$privateLinkDB->filterDay('20121206'))
+        );
+    }
+
+    /**
+     * 404 - day not found
+     */
+    public function testFilterUnknownDay()
+    {
+        $this->assertEquals(
+            0,
+            sizeof(self::$publicLinkDB->filterDay('19700101'))
+        );
+
+        $this->assertEquals(
+            0,
+            sizeof(self::$privateLinkDB->filterDay('19700101'))
+        );
+    }
+
+    /**
+     * Use an invalid date format
+     */
+    public function testFilterInvalidDay()
+    {
+        $this->assertEquals(
+            0,
+            sizeof(self::$privateLinkDB->filterDay('Rainy day, dream away'))
+        );
+
+        // TODO: check input format
+        $this->assertEquals(
+            6,
+            sizeof(self::$privateLinkDB->filterDay('20'))
+        );
+    }
+
+    /**
+     * Retrieve a link entry with its hash
+     */
+    public function testFilterSmallHash()
+    {
+        $links = self::$privateLinkDB->filterSmallHash('IuWvgA');
+
+        $this->assertEquals(
+            1,
+            sizeof($links)
+        );
+
+        $this->assertEquals(
+            'MediaGoblin',
+            $links['20130614_184135']['title']
+        );
+        
+    }
+
+    /**
+     * No link for this hash
+     */
+    public function testFilterUnknownSmallHash()
+    {
+        $this->assertEquals(
+            0,
+            sizeof(self::$privateLinkDB->filterSmallHash('Iblaah'))
+        );
+    }
+
+    /**
+     * Full-text search - result from a link's URL
+     */
+    public function testFilterFullTextURL()
+    {
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org'))
+        );
+    }
+
+    /**
+     * Full-text search - result from a link's title only
+     */
+    public function testFilterFullTextTitle()
+    {
+        // use miscellaneous cases
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('userfriendly -'))
+        );
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('UserFriendly -'))
+        );
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -'))
+        );
+
+        // use miscellaneous case and offset
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('RFrIendL'))
+        );
+    }
+
+    /**
+     * Full-text search - result from the link's description only
+     */
+    public function testFilterFullTextDescription()
+    {
+        $this->assertEquals(
+            1,
+            sizeof(self::$publicLinkDB->filterFullText('media publishing'))
+        );
+    }
+
+    /**
+     * Full-text search - result from the link's tags only
+     */
+    public function testFilterFullTextTags()
+    {
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('gnu'))
+        );
+    }
+
+    /**
+     * Full-text search - result set from mixed sources
+     */
+    public function testFilterFullTextMixed()
+    {
+        $this->assertEquals(
+            2,
+            sizeof(self::$publicLinkDB->filterFullText('free software'))
+        );
+    }
+}
+?>
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
new file mode 100644 (file)
index 0000000..bbba99f
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Utilities' tests
+ */
+
+require_once 'application/Utils.php';
+
+/**
+ * Unitary tests for Shaarli utilities
+ */
+class UtilsTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * Represent a link by its hash
+     */
+    public function testSmallHash()
+    {
+        $this->assertEquals('CyAAJw', smallHash('http://test.io'));
+        $this->assertEquals(6, strlen(smallHash('https://github.com')));
+    }
+
+    /**
+     * Look for a substring at the beginning of a string
+     */
+    public function testStartsWithCaseInsensitive()
+    {
+        $this->assertTrue(startsWith('Lorem ipsum', 'lorem', false));
+        $this->assertTrue(startsWith('Lorem ipsum', 'LoReM i', false));
+    }
+
+    /**
+     * Look for a substring at the beginning of a string (case-sensitive)
+     */
+    public function testStartsWithCaseSensitive()
+    {
+        $this->assertTrue(startsWith('Lorem ipsum', 'Lorem', true));
+        $this->assertFalse(startsWith('Lorem ipsum', 'lorem', true));
+        $this->assertFalse(startsWith('Lorem ipsum', 'LoReM i', true));
+    }
+
+    /**
+     * Look for a substring at the beginning of a string (Unicode)
+     */
+    public function testStartsWithSpecialChars()
+    {
+        $this->assertTrue(startsWith('å!ùµ', 'å!', false));
+        $this->assertTrue(startsWith('µ$åù', 'µ$', true));
+    }
+
+    /**
+     * Look for a substring at the end of a string
+     */
+    public function testEndsWithCaseInsensitive()
+    {
+        $this->assertTrue(endsWith('Lorem ipsum', 'ipsum', false));
+        $this->assertTrue(endsWith('Lorem ipsum', 'm IpsUM', false));
+    }
+
+    /**
+     * Look for a substring at the end of a string (case-sensitive)
+     */
+    public function testEndsWithCaseSensitive()
+    {
+        $this->assertTrue(endsWith('lorem Ipsum', 'Ipsum', true));
+        $this->assertFalse(endsWith('lorem Ipsum', 'ipsum', true));
+        $this->assertFalse(endsWith('lorem Ipsum', 'M IPsuM', true));
+    }
+
+    /**
+     * Look for a substring at the end of a string (Unicode)
+     */
+    public function testEndsWithSpecialChars()
+    {
+        $this->assertTrue(endsWith('å!ùµ', 'ùµ', false));
+        $this->assertTrue(endsWith('µ$åù', 'åù', true));
+    }
+}
+?>
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php
new file mode 100644 (file)
index 0000000..2cb05ba
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Populates a reference datastore to test LinkDB
+ */
+class ReferenceLinkDB
+{
+    private $links = array();
+    private $publicCount = 0;
+    private $privateCount = 0;
+
+    /**
+     * Populates the test DB with reference data
+     */
+    function __construct()
+    {
+        $this->addLink(
+            'Free as in Freedom 2.0',
+            'https://static.fsf.org/nosvn/faif-2.0.pdf',
+            'Richard Stallman and the Free Software Revolution',
+            0,
+            '20150310_114633',
+            'free gnu software stallman'
+        );
+
+        $this->addLink(
+            'MediaGoblin',
+            'http://mediagoblin.org/',
+            'A free software media publishing platform',
+            0,
+            '20130614_184135',
+            'gnu media web'
+        );
+
+        $this->addLink(
+            'w3c-markup-validator',
+            'https://dvcs.w3.org/hg/markup-validator/summary',
+            'Mercurial repository for the W3C Validator',
+            1,
+            '20141125_084734',
+            'css html w3c web Mercurial'
+        );
+
+        $this->addLink(
+            'UserFriendly - Web Designer',
+            'http://ars.userfriendly.org/cartoons/?id=20121206',
+            'Naming conventions...',
+            0,
+            '20121206_142300',
+            'dev cartoon web'
+        );
+
+        $this->addLink(
+            'UserFriendly - Samba',
+            'http://ars.userfriendly.org/cartoons/?id=20010306',
+            'Tropical printing',
+            0,
+            '20121206_172539',
+            'samba cartoon web'
+        );
+
+        $this->addLink(
+            'Geek and Poke',
+            'http://geek-and-poke.com/',
+            '',
+            1,
+            '20121206_182539',
+            'dev cartoon'
+        );
+    }
+
+    /**
+     * Adds a new link
+     */
+    protected function addLink($title, $url, $description, $private, $date, $tags)
+    {
+        $link = array(
+            'title' => $title,
+            'url' => $url,
+            'description' => $description,
+            'private' => $private,
+            'linkdate' => $date,
+            'tags' => $tags,
+        );
+        $this->links[$date] = $link;
+
+        if ($private) {
+            $this->privateCount++;
+            return;
+        }
+        $this->publicCount++;
+    }
+
+    /**
+     * Writes data to the datastore
+     */
+    public function write($filename, $prefix, $suffix)
+    {
+        file_put_contents(
+            $filename,
+            $prefix.base64_encode(gzdeflate(serialize($this->links))).$suffix
+        );
+    }
+
+    /**
+     * Returns the number of links in the reference data
+     */
+    public function countLinks()
+    {
+        return $this->publicCount + $this->privateCount;
+    }
+
+    /**
+     * Returns the number of public links in the reference data
+     */
+    public function countPublicLinks()
+    {
+        return $this->publicCount;
+    }
+
+    /**
+     * Returns the number of private links in the reference data
+     */
+    public function countPrivateLinks()
+    {
+        return $this->privateCount;
+    }
+}
+?>