From ca74886f30da323f42aa4bd70461003f46ef299b Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Thu, 12 Mar 2015 00:43:02 +0100 Subject: [PATCH] LinkDB: move to a proper file, add test coverage 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 --- .gitignore | 4 +- Makefile | 33 ++- application/.htaccess | 2 + application/LinkDB.php | 412 ++++++++++++++++++++++++++ application/Utils.php | 45 +++ composer.json | 1 + index.php | 259 +--------------- phpunit.xml | 15 + tests/.htaccess | 2 + tests/LinkDBTest.php | 509 ++++++++++++++++++++++++++++++++ tests/UtilsTest.php | 78 +++++ tests/utils/ReferenceLinkDB.php | 128 ++++++++ 12 files changed, 1231 insertions(+), 257 deletions(-) create mode 100644 application/.htaccess create mode 100644 application/LinkDB.php create mode 100644 application/Utils.php create mode 100644 phpunit.xml create mode 100644 tests/.htaccess create mode 100644 tests/LinkDBTest.php create mode 100644 tests/UtilsTest.php create mode 100644 tests/utils/ReferenceLinkDB.php diff --git a/.gitignore b/.gitignore index 33d8a48..6fd0ccd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,7 @@ pagecache composer.lock /vendor/ -# Ignore test output +# Ignore test data & output +coverage +tests/datastore.php phpmd.html diff --git a/Makefile b/Makefile index e6f4285..80efcfa 100644 --- 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 index 0000000..b584d98 --- /dev/null +++ b/application/.htaccess @@ -0,0 +1,2 @@ +Allow from none +Deny from all diff --git a/application/LinkDB.php b/application/LinkDB.php new file mode 100644 index 0000000..388002f --- /dev/null +++ b/application/LinkDB.php @@ -0,0 +1,412 @@ +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 index 0000000..737f150 --- /dev/null +++ b/application/Utils.php @@ -0,0 +1,45 @@ + 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); +} +?> diff --git a/composer.json b/composer.json index d1f613c..f6d92c9 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": {}, "require-dev": { "phpmd/phpmd" : "@stable", + "phpunit/phpunit": "4.6.*", "sebastian/phpcpd": "*", "squizlabs/php_codesniffer": "2.*" } diff --git a/index.php b/index.php index 9561f63..ed18c7f 100644 --- 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('>','>',str_replace('<','<',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 index 0000000..d6e01c3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + + application + + + + + + + diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 0000000..b584d98 --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1,2 @@ +Allow from none +Deny from all diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php new file mode 100644 index 0000000..bbe4e02 --- /dev/null +++ b/tests/LinkDBTest.php @@ -0,0 +1,509 @@ +'); + + +/** + * 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 index 0000000..bbba99f --- /dev/null +++ b/tests/UtilsTest.php @@ -0,0 +1,78 @@ +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 index 0000000..2cb05ba --- /dev/null +++ b/tests/utils/ReferenceLinkDB.php @@ -0,0 +1,128 @@ +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; + } +} +?>