aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--Makefile33
-rw-r--r--application/.htaccess2
-rw-r--r--application/LinkDB.php412
-rw-r--r--application/Utils.php45
-rw-r--r--composer.json1
-rw-r--r--index.php259
-rw-r--r--phpunit.xml15
-rw-r--r--tests/.htaccess2
-rw-r--r--tests/LinkDBTest.php509
-rw-r--r--tests/UtilsTest.php78
-rw-r--r--tests/utils/ReferenceLinkDB.php128
12 files changed, 1231 insertions, 257 deletions
diff --git a/.gitignore b/.gitignore
index 33d8a488..6fd0ccd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,5 +16,7 @@ pagecache
16composer.lock 16composer.lock
17/vendor/ 17/vendor/
18 18
19# Ignore test output 19# Ignore test data & output
20coverage
21tests/datastore.php
20phpmd.html 22phpmd.html
diff --git a/Makefile b/Makefile
index e6f42853..80efcfaa 100644
--- a/Makefile
+++ b/Makefile
@@ -8,12 +8,15 @@
8# - install/update test dependencies: 8# - install/update test dependencies:
9# $ composer install # 1st setup 9# $ composer install # 1st setup
10# $ composer update 10# $ composer update
11# - install Xdebug for PHPUnit code coverage reports:
12# - see http://xdebug.org/docs/install
13# - enable in php.ini
11 14
12BIN = vendor/bin 15BIN = vendor/bin
13PHP_SOURCE = index.php 16PHP_SOURCE = index.php application tests
14MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode 17PHP_COMMA_SOURCE = index.php,application,tests
15 18
16all: static_analysis_summary 19all: static_analysis_summary test
17 20
18## 21##
19# Concise status of the project 22# Concise status of the project
@@ -21,6 +24,7 @@ all: static_analysis_summary
21## 24##
22 25
23static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary 26static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
27 @echo
24 28
25## 29##
26# PHP_CodeSniffer 30# PHP_CodeSniffer
@@ -62,6 +66,7 @@ copy_paste:
62# Detects PHP syntax errors, sorted by category 66# Detects PHP syntax errors, sorted by category
63# Rules documentation: http://phpmd.org/rules/index.html 67# Rules documentation: http://phpmd.org/rules/index.html
64## 68##
69MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
65 70
66mess_title: 71mess_title:
67 @echo "-----------------" 72 @echo "-----------------"
@@ -70,11 +75,11 @@ mess_title:
70 75
71### - all warnings 76### - all warnings
72mess_detector: mess_title 77mess_detector: mess_title
73 @$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__' 78 @$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
74 79
75### - all warnings + HTML output contains links to PHPMD's documentation 80### - all warnings + HTML output contains links to PHPMD's documentation
76mess_detector_html: 81mess_detector_html:
77 @$(BIN)/phpmd $(PHP_SOURCE) html $(MESS_DETECTOR_RULES) \ 82 @$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
78 --reportfile phpmd.html || exit 0 83 --reportfile phpmd.html || exit 0
79 84
80### - warnings grouped by message, sorted by descending frequency order 85### - warnings grouped by message, sorted by descending frequency order
@@ -85,11 +90,25 @@ mess_detector_grouped: mess_title
85### - summary: number of warnings by rule set 90### - summary: number of warnings by rule set
86mess_detector_summary: mess_title 91mess_detector_summary: mess_title
87 @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \ 92 @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
88 warnings=$$($(BIN)/phpmd $(PHP_SOURCE) text $$rule | wc -l); \ 93 warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
89 printf "$$warnings\t$$rule\n"; \ 94 printf "$$warnings\t$$rule\n"; \
90 done; 95 done;
91 96
92## 97##
98# PHPUnit
99# Runs unitary and functional tests
100# Generates an HTML coverage report if Xdebug is enabled
101#
102# See phpunit.xml for configuration
103# https://phpunit.de/manual/current/en/appendixes.configuration.html
104##
105test: clean
106 @echo "-------"
107 @echo "PHPUNIT"
108 @echo "-------"
109 @$(BIN)/phpunit tests
110
111##
93# Targets for repository and documentation maintenance 112# Targets for repository and documentation maintenance
94## 113##
95 114
@@ -107,4 +126,4 @@ doc: clean
107htmldoc: 126htmldoc:
108 for file in `find doc/ -maxdepth 1 -name "*.md"`; do \ 127 for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
109 pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \ 128 pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \
110 done; \ No newline at end of file 129 done;
diff --git a/application/.htaccess b/application/.htaccess
new file mode 100644
index 00000000..b584d98c
--- /dev/null
+++ b/application/.htaccess
@@ -0,0 +1,2 @@
1Allow from none
2Deny from all
diff --git a/application/LinkDB.php b/application/LinkDB.php
new file mode 100644
index 00000000..388002f6
--- /dev/null
+++ b/application/LinkDB.php
@@ -0,0 +1,412 @@
1<?php
2/**
3 * Data storage for links.
4 *
5 * This object behaves like an associative array.
6 *
7 * Example:
8 * $myLinks = new LinkDB();
9 * echo $myLinks['20110826_161819']['title'];
10 * foreach ($myLinks as $link)
11 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
12 *
13 * Available keys:
14 * - description: description of the entry
15 * - linkdate: date of the creation of this entry, in the form YYYYMMDD_HHMMSS
16 * (e.g.'20110914_192317')
17 * - private: Is this link private? 0=no, other value=yes
18 * - tags: tags attached to this entry (separated by spaces)
19 * - title Title of the link
20 * - url URL of the link. Can be absolute or relative.
21 * Relative URLs are permalinks (e.g.'?m-ukcw')
22 *
23 * Implements 3 interfaces:
24 * - ArrayAccess: behaves like an associative array;
25 * - Countable: there is a count() method;
26 * - Iterator: usable in foreach () loops.
27 */
28class LinkDB implements Iterator, Countable, ArrayAccess
29{
30 // List of links (associative array)
31 // - key: link date (e.g. "20110823_124546"),
32 // - value: associative array (keys: title, description...)
33 private $links;
34
35 // List of all recorded URLs (key=url, value=linkdate)
36 // for fast reserve search (url-->linkdate)
37 private $urls;
38
39 // List of linkdate keys (for the Iterator interface implementation)
40 private $keys;
41
42 // Position in the $this->keys array (for the Iterator interface)
43 private $position;
44
45 // Is the user logged in? (used to filter private links)
46 private $loggedIn;
47
48 /**
49 * Creates a new LinkDB
50 *
51 * Checks if the datastore exists; else, attempts to create a dummy one.
52 *
53 * @param $isLoggedIn is the user logged in?
54 */
55 function __construct($isLoggedIn)
56 {
57 // FIXME: do not access $GLOBALS, pass the datastore instead
58 $this->loggedIn = $isLoggedIn;
59 $this->checkDB();
60 $this->readdb();
61 }
62
63 /**
64 * Countable - Counts elements of an object
65 */
66 public function count()
67 {
68 return count($this->links);
69 }
70
71 /**
72 * ArrayAccess - Assigns a value to the specified offset
73 */
74 public function offsetSet($offset, $value)
75 {
76 // TODO: use exceptions instead of "die"
77 if (!$this->loggedIn) {
78 die('You are not authorized to add a link.');
79 }
80 if (empty($value['linkdate']) || empty($value['url'])) {
81 die('Internal Error: A link should always have a linkdate and URL.');
82 }
83 if (empty($offset)) {
84 die('You must specify a key.');
85 }
86 $this->links[$offset] = $value;
87 $this->urls[$value['url']]=$offset;
88 }
89
90 /**
91 * ArrayAccess - Whether or not an offset exists
92 */
93 public function offsetExists($offset)
94 {
95 return array_key_exists($offset, $this->links);
96 }
97
98 /**
99 * ArrayAccess - Unsets an offset
100 */
101 public function offsetUnset($offset)
102 {
103 if (!$this->loggedIn) {
104 // TODO: raise an exception
105 die('You are not authorized to delete a link.');
106 }
107 $url = $this->links[$offset]['url'];
108 unset($this->urls[$url]);
109 unset($this->links[$offset]);
110 }
111
112 /**
113 * ArrayAccess - Returns the value at specified offset
114 */
115 public function offsetGet($offset)
116 {
117 return isset($this->links[$offset]) ? $this->links[$offset] : null;
118 }
119
120 /**
121 * Iterator - Returns the current element
122 */
123 function current()
124 {
125 return $this->links[$this->keys[$this->position]];
126 }
127
128 /**
129 * Iterator - Returns the key of the current element
130 */
131 function key()
132 {
133 return $this->keys[$this->position];
134 }
135
136 /**
137 * Iterator - Moves forward to next element
138 */
139 function next()
140 {
141 ++$this->position;
142 }
143
144 /**
145 * Iterator - Rewinds the Iterator to the first element
146 *
147 * Entries are sorted by date (latest first)
148 */
149 function rewind()
150 {
151 $this->keys = array_keys($this->links);
152 rsort($this->keys);
153 $this->position = 0;
154 }
155
156 /**
157 * Iterator - Checks if current position is valid
158 */
159 function valid()
160 {
161 return isset($this->keys[$this->position]);
162 }
163
164 /**
165 * Checks if the DB directory and file exist
166 *
167 * If no DB file is found, creates a dummy DB.
168 */
169 private function checkDB()
170 {
171 if (file_exists($GLOBALS['config']['DATASTORE'])) {
172 return;
173 }
174
175 // Create a dummy database for example
176 $this->links = array();
177 $link = array(
178 'title'=>'Shaarli - sebsauvage.net',
179 'url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli',
180 'description'=>'Welcome to Shaarli! This is a bookmark. To edit or delete me, you must first login.',
181 'private'=>0,
182 'linkdate'=>'20110914_190000',
183 'tags'=>'opensource software'
184 );
185 $this->links[$link['linkdate']] = $link;
186
187 $link = array(
188 'title'=>'My secret stuff... - Pastebin.com',
189 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
190 'description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.',
191 'private'=>1,
192 'linkdate'=>'20110914_074522',
193 'tags'=>'secretstuff'
194 );
195 $this->links[$link['linkdate']] = $link;
196
197 // Write database to disk
198 // TODO: raise an exception if the file is not write-able
199 file_put_contents(
200 // FIXME: do not use $GLOBALS
201 $GLOBALS['config']['DATASTORE'],
202 PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
203 );
204 }
205
206 /**
207 * Reads database from disk to memory
208 */
209 private function readdb()
210 {
211 // Read data
212 // Note that gzinflate is faster than gzuncompress.
213 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
214 // FIXME: do not use $GLOBALS
215 $this->links = array();
216
217 if (file_exists($GLOBALS['config']['DATASTORE'])) {
218 $this->links = unserialize(gzinflate(base64_decode(
219 substr(file_get_contents($GLOBALS['config']['DATASTORE']),
220 strlen(PHPPREFIX), -strlen(PHPSUFFIX)))));
221 }
222
223 // If user is not logged in, filter private links.
224 if (!$this->loggedIn) {
225 $toremove = array();
226 foreach ($this->links as $link) {
227 if ($link['private'] != 0) {
228 $toremove[] = $link['linkdate'];
229 }
230 }
231 foreach ($toremove as $linkdate) {
232 unset($this->links[$linkdate]);
233 }
234 }
235
236 // Keep the list of the mapping URLs-->linkdate up-to-date.
237 $this->urls = array();
238 foreach ($this->links as $link) {
239 $this->urls[$link['url']] = $link['linkdate'];
240 }
241 }
242
243 /**
244 * Saves the database from memory to disk
245 */
246 public function savedb()
247 {
248 if (!$this->loggedIn) {
249 // TODO: raise an Exception instead
250 die('You are not authorized to change the database.');
251 }
252 file_put_contents(
253 $GLOBALS['config']['DATASTORE'],
254 PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
255 );
256 invalidateCaches();
257 }
258
259 /**
260 * Returns the link for a given URL, or False if it does not exist.
261 */
262 public function getLinkFromUrl($url)
263 {
264 if (isset($this->urls[$url])) {
265 return $this->links[$this->urls[$url]];
266 }
267 return false;
268 }
269
270 /**
271 * Returns the list of links corresponding to a full-text search
272 *
273 * Searches:
274 * - in the URLs, title and description;
275 * - are case-insensitive.
276 *
277 * Example:
278 * print_r($mydb->filterFulltext('hollandais'));
279 *
280 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
281 * - allows to perform searches on Unicode text
282 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
283 */
284 public function filterFulltext($searchterms)
285 {
286 // FIXME: explode(' ',$searchterms) and perform a AND search.
287 // FIXME: accept double-quotes to search for a string "as is"?
288 $filtered = array();
289 $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
290 $keys = ['title', 'description', 'url', 'tags'];
291
292 foreach ($this->links as $link) {
293 $found = false;
294
295 foreach ($keys as $key) {
296 if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
297 $search) !== false) {
298 $found = true;
299 }
300 }
301
302 if ($found) {
303 $filtered[$link['linkdate']] = $link;
304 }
305 }
306 krsort($filtered);
307 return $filtered;
308 }
309
310 /**
311 * Returns the list of links associated with a given list of tags
312 *
313 * You can specify one or more tags, separated by space or a comma, e.g.
314 * print_r($mydb->filterTags('linux programming'));
315 */
316 public function filterTags($tags, $casesensitive=false)
317 {
318 // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
319 // FIXME: is $casesensitive ever true?
320 $t = str_replace(
321 ',', ' ',
322 ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))
323 );
324
325 $searchtags = explode(' ', $t);
326 $filtered = array();
327
328 foreach ($this->links as $l) {
329 $linktags = explode(
330 ' ',
331 ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
332 );
333
334 if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
335 $filtered[$l['linkdate']] = $l;
336 }
337 }
338 krsort($filtered);
339 return $filtered;
340 }
341
342
343 /**
344 * Returns the list of articles for a given day, chronologically sorted
345 *
346 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
347 * print_r($mydb->filterDay('20120125'));
348 */
349 public function filterDay($day)
350 {
351 // TODO: check input format
352 $filtered = array();
353 foreach ($this->links as $l) {
354 if (startsWith($l['linkdate'], $day)) {
355 $filtered[$l['linkdate']] = $l;
356 }
357 }
358 ksort($filtered);
359 return $filtered;
360 }
361
362 /**
363 * Returns the article corresponding to a smallHash
364 */
365 public function filterSmallHash($smallHash)
366 {
367 $filtered = array();
368 foreach ($this->links as $l) {
369 if ($smallHash == smallHash($l['linkdate'])) {
370 // Yes, this is ugly and slow
371 $filtered[$l['linkdate']] = $l;
372 return $filtered;
373 }
374 }
375 return $filtered;
376 }
377
378 /**
379 * Returns the list of all tags
380 * Output: associative array key=tags, value=0
381 */
382 public function allTags()
383 {
384 $tags = array();
385 foreach ($this->links as $link) {
386 foreach (explode(' ', $link['tags']) as $tag) {
387 if (!empty($tag)) {
388 $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1);
389 }
390 }
391 }
392 // Sort tags by usage (most used tag first)
393 arsort($tags);
394 return $tags;
395 }
396
397 /**
398 * Returns the list of days containing articles (oldest first)
399 * Output: An array containing days (in format YYYYMMDD).
400 */
401 public function days()
402 {
403 $linkDays = array();
404 foreach (array_keys($this->links) as $day) {
405 $linkDays[substr($day, 0, 8)] = 0;
406 }
407 $linkDays = array_keys($linkDays);
408 sort($linkDays);
409 return $linkDays;
410 }
411}
412?>
diff --git a/application/Utils.php b/application/Utils.php
new file mode 100644
index 00000000..737f1502
--- /dev/null
+++ b/application/Utils.php
@@ -0,0 +1,45 @@
1<?php
2/**
3 * Shaarli utilities
4 */
5
6/**
7 * Returns the small hash of a string, using RFC 4648 base64url format
8 *
9 * Small hashes:
10 * - are unique (well, as unique as crc32, at last)
11 * - are always 6 characters long.
12 * - only use the following characters: a-z A-Z 0-9 - _ @
13 * - are NOT cryptographically secure (they CAN be forged)
14 *
15 * In Shaarli, they are used as a tinyurl-like link to individual entries,
16 * e.g. smallHash('20111006_131924') --> yZH23w
17 */
18function smallHash($text)
19{
20 $t = rtrim(base64_encode(hash('crc32', $text, true)), '=');
21 return strtr($t, '+/', '-_');
22}
23
24/**
25 * Tells if a string start with a substring
26 */
27function startsWith($haystack, $needle, $case=true)
28{
29 if ($case) {
30 return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
31 }
32 return (strcasecmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
33}
34
35/**
36 * Tells if a string ends with a substring
37 */
38function endsWith($haystack, $needle, $case=true)
39{
40 if ($case) {
41 return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
42 }
43 return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
44}
45?>
diff --git a/composer.json b/composer.json
index d1f613c1..f6d92c92 100644
--- a/composer.json
+++ b/composer.json
@@ -8,6 +8,7 @@
8 "require": {}, 8 "require": {},
9 "require-dev": { 9 "require-dev": {
10 "phpmd/phpmd" : "@stable", 10 "phpmd/phpmd" : "@stable",
11 "phpunit/phpunit": "4.6.*",
11 "sebastian/phpcpd": "*", 12 "sebastian/phpcpd": "*",
12 "squizlabs/php_codesniffer": "2.*" 13 "squizlabs/php_codesniffer": "2.*"
13 } 14 }
diff --git a/index.php b/index.php
index 9561f63b..ed18c7f9 100644
--- a/index.php
+++ b/index.php
@@ -68,6 +68,10 @@ checkphpversion();
68error_reporting(E_ALL^E_WARNING); // See all error except warnings. 68error_reporting(E_ALL^E_WARNING); // See all error except warnings.
69//error_reporting(-1); // See all errors (for debugging only) 69//error_reporting(-1); // See all errors (for debugging only)
70 70
71// Shaarli library
72require_once 'application/LinkDB.php';
73require_once 'application/Utils.php';
74
71include "inc/rain.tpl.class.php"; //include Rain TPL 75include "inc/rain.tpl.class.php"; //include Rain TPL
72raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory 76raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
73raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory 77raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory
@@ -268,21 +272,6 @@ function nl2br_escaped($html)
268 return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html))); 272 return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html)));
269} 273}
270 274
271/* Returns the small hash of a string, using RFC 4648 base64url format
272 e.g. smallHash('20111006_131924') --> yZH23w
273 Small hashes:
274 - are unique (well, as unique as crc32, at last)
275 - are always 6 characters long.
276 - only use the following characters: a-z A-Z 0-9 - _ @
277 - are NOT cryptographically secure (they CAN be forged)
278 In Shaarli, they are used as a tinyurl-like link to individual entries.
279*/
280function smallHash($text)
281{
282 $t = rtrim(base64_encode(hash('crc32',$text,true)),'=');
283 return strtr($t, '+/', '-_');
284}
285
286// In a string, converts URLs to clickable links. 275// In a string, converts URLs to clickable links.
287// Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 276// Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
288function text2clickable($url) 277function text2clickable($url)
@@ -536,20 +525,6 @@ function getMaxFileSize()
536 return $maxsize; 525 return $maxsize;
537} 526}
538 527
539// Tells if a string start with a substring or not.
540function startsWith($haystack,$needle,$case=true)
541{
542 if($case){return (strcmp(substr($haystack, 0, strlen($needle)),$needle)===0);}
543 return (strcasecmp(substr($haystack, 0, strlen($needle)),$needle)===0);
544}
545
546// Tells if a string ends with a substring or not.
547function endsWith($haystack,$needle,$case=true)
548{
549 if($case){return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);}
550 return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);
551}
552
553/* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch) 528/* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch)
554 (used to build the ADD_DATE attribute in Netscape-bookmarks file) 529 (used to build the ADD_DATE attribute in Netscape-bookmarks file)
555 PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */ 530 PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */
@@ -711,220 +686,6 @@ class pageBuilder
711} 686}
712 687
713// ------------------------------------------------------------------------------------------ 688// ------------------------------------------------------------------------------------------
714/* Data storage for links.
715 This object behaves like an associative array.
716 Example:
717 $mylinks = new linkdb();
718 echo $mylinks['20110826_161819']['title'];
719 foreach($mylinks as $link)
720 echo $link['title'].' at url '.$link['url'].' ; description:'.$link['description'];
721
722 Available keys:
723 title : Title of the link
724 url : URL of the link. Can be absolute or relative. Relative URLs are permalinks (e.g.'?m-ukcw')
725 description : description of the entry
726 private : Is this link private? 0=no, other value=yes
727 linkdate : date of the creation of this entry, in the form YYYYMMDD_HHMMSS (e.g.'20110914_192317')
728 tags : tags attached to this entry (separated by spaces)
729
730 We implement 3 interfaces:
731 - ArrayAccess so that this object behaves like an associative array.
732 - Iterator so that this object can be used in foreach() loops.
733 - Countable interface so that we can do a count() on this object.
734*/
735class linkdb implements Iterator, Countable, ArrayAccess
736{
737 private $links; // List of links (associative array. Key=linkdate (e.g. "20110823_124546"), value= associative array (keys:title,description...)
738 private $urls; // List of all recorded URLs (key=url, value=linkdate) for fast reserve search (url-->linkdate)
739 private $keys; // List of linkdate keys (for the Iterator interface implementation)
740 private $position; // Position in the $this->keys array. (for the Iterator interface implementation.)
741 private $loggedin; // Is the user logged in? (used to filter private links)
742
743 // Constructor:
744 function __construct($isLoggedIn)
745 // Input : $isLoggedIn : is the user logged in?
746 {
747 $this->loggedin = $isLoggedIn;
748 $this->checkdb(); // Make sure data file exists.
749 $this->readdb(); // Then read it.
750 }
751
752 // ---- Countable interface implementation
753 public function count() { return count($this->links); }
754
755 // ---- ArrayAccess interface implementation
756 public function offsetSet($offset, $value)
757 {
758 if (!$this->loggedin) die('You are not authorized to add a link.');
759 if (empty($value['linkdate']) || empty($value['url'])) die('Internal Error: A link should always have a linkdate and URL.');
760 if (empty($offset)) die('You must specify a key.');
761 $this->links[$offset] = $value;
762 $this->urls[$value['url']]=$offset;
763 }
764 public function offsetExists($offset) { return array_key_exists($offset,$this->links); }
765 public function offsetUnset($offset)
766 {
767 if (!$this->loggedin) die('You are not authorized to delete a link.');
768 $url = $this->links[$offset]['url']; unset($this->urls[$url]);
769 unset($this->links[$offset]);
770 }
771 public function offsetGet($offset) { return isset($this->links[$offset]) ? $this->links[$offset] : null; }
772
773 // ---- Iterator interface implementation
774 function rewind() { $this->keys=array_keys($this->links); rsort($this->keys); $this->position=0; } // Start over for iteration, ordered by date (latest first).
775 function key() { return $this->keys[$this->position]; } // current key
776 function current() { return $this->links[$this->keys[$this->position]]; } // current value
777 function next() { ++$this->position; } // go to next item
778 function valid() { return isset($this->keys[$this->position]); } // Check if current position is valid.
779
780 // ---- Misc methods
781 private function checkdb() // Check if db directory and file exists.
782 {
783 if (!file_exists($GLOBALS['config']['DATASTORE'])) // Create a dummy database for example.
784 {
785 $this->links = array();
786 $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');
787 $this->links[$link['linkdate']] = $link;
788 $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');
789 $this->links[$link['linkdate']] = $link;
790 file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); // Write database to disk
791 }
792 }
793
794 // Read database from disk to memory
795 private function readdb()
796 {
797 // Read data
798 $this->links=(file_exists($GLOBALS['config']['DATASTORE']) ? unserialize(gzinflate(base64_decode(substr(file_get_contents($GLOBALS['config']['DATASTORE']),strlen(PHPPREFIX),-strlen(PHPSUFFIX))))) : array() );
799 // Note that gzinflate is faster than gzuncompress. See: http://www.php.net/manual/en/function.gzdeflate.php#96439
800
801 // If user is not logged in, filter private links.
802 if (!$this->loggedin)
803 {
804 $toremove=array();
805 foreach($this->links as $link) { if ($link['private']!=0) $toremove[]=$link['linkdate']; }
806 foreach($toremove as $linkdate) { unset($this->links[$linkdate]); }
807 }
808
809 // Keep the list of the mapping URLs-->linkdate up-to-date.
810 $this->urls=array();
811 foreach($this->links as $link) { $this->urls[$link['url']]=$link['linkdate']; }
812 }
813
814 // Save database from memory to disk.
815 public function savedb()
816 {
817 if (!$this->loggedin) die('You are not authorized to change the database.');
818 file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX);
819 invalidateCaches();
820 }
821
822 // Returns the link for a given URL (if it exists). False if it does not exist.
823 public function getLinkFromUrl($url)
824 {
825 if (isset($this->urls[$url])) return $this->links[$this->urls[$url]];
826 return false;
827 }
828
829 // Case insensitive search among links (in the URLs, title and description). Returns filtered list of links.
830 // e.g. print_r($mydb->filterFulltext('hollandais'));
831 public function filterFulltext($searchterms)
832 {
833 // FIXME: explode(' ',$searchterms) and perform a AND search.
834 // FIXME: accept double-quotes to search for a string "as is"?
835 // Using mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') allows us to perform searches on
836 // Unicode text. See https://github.com/shaarli/Shaarli/issues/75 for examples.
837 $filtered=array();
838 $s = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
839 foreach($this->links as $l)
840 {
841 $found= (strpos(mb_convert_case($l['title'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
842 || (strpos(mb_convert_case($l['description'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
843 || (strpos(mb_convert_case($l['url'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
844 || (strpos(mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'),$s) !== false);
845 if ($found) $filtered[$l['linkdate']] = $l;
846 }
847 krsort($filtered);
848 return $filtered;
849 }
850
851 // Filter by tag.
852 // You can specify one or more tags (tags can be separated by space or comma).
853 // e.g. print_r($mydb->filterTags('linux programming'));
854 public function filterTags($tags,$casesensitive=false)
855 {
856 // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
857 // TODO: is $casesensitive ever true ?
858 $t = str_replace(',',' ',($casesensitive?$tags:mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8')));
859 $searchtags=explode(' ',$t);
860 $filtered=array();
861 foreach($this->links as $l)
862 {
863 $linktags = explode(' ',($casesensitive?$l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8')));
864 if (count(array_intersect($linktags,$searchtags)) == count($searchtags))
865 $filtered[$l['linkdate']] = $l;
866 }
867 krsort($filtered);
868 return $filtered;
869 }
870
871 // Filter by day. Day must be in the form 'YYYYMMDD' (e.g. '20120125')
872 // Sort order is: older articles first.
873 // e.g. print_r($mydb->filterDay('20120125'));
874 public function filterDay($day)
875 {
876 $filtered=array();
877 foreach($this->links as $l)
878 {
879 if (startsWith($l['linkdate'],$day)) $filtered[$l['linkdate']] = $l;
880 }
881 ksort($filtered);
882 return $filtered;
883 }
884 // Filter by smallHash.
885 // Only 1 article is returned.
886 public function filterSmallHash($smallHash)
887 {
888 $filtered=array();
889 foreach($this->links as $l)
890 {
891 if ($smallHash==smallHash($l['linkdate'])) // Yes, this is ugly and slow
892 {
893 $filtered[$l['linkdate']] = $l;
894 return $filtered;
895 }
896 }
897 return $filtered;
898 }
899
900 // Returns the list of all tags
901 // Output: associative array key=tags, value=0
902 public function allTags()
903 {
904 $tags=array();
905 foreach($this->links as $link)
906 foreach(explode(' ',$link['tags']) as $tag)
907 if (!empty($tag)) $tags[$tag]=(empty($tags[$tag]) ? 1 : $tags[$tag]+1);
908 arsort($tags); // Sort tags by usage (most used tag first)
909 return $tags;
910 }
911
912 // Returns the list of days containing articles (oldest first)
913 // Output: An array containing days (in format YYYYMMDD).
914 public function days()
915 {
916 $linkdays=array();
917 foreach(array_keys($this->links) as $day)
918 {
919 $linkdays[substr($day,0,8)]=0;
920 }
921 $linkdays=array_keys($linkdays);
922 sort($linkdays);
923 return $linkdays;
924 }
925}
926
927// ------------------------------------------------------------------------------------------
928// Output the last N links in RSS 2.0 format. 689// Output the last N links in RSS 2.0 format.
929function showRSS() 690function showRSS()
930{ 691{
@@ -941,7 +702,7 @@ function showRSS()
941 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 702 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
942 703
943 // If cached was not found (or not usable), then read the database and build the response: 704 // If cached was not found (or not usable), then read the database and build the response:
944 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in). 705 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
945 706
946 // Optionally filter the results: 707 // Optionally filter the results:
947 $linksToDisplay=array(); 708 $linksToDisplay=array();
@@ -1019,7 +780,7 @@ function showATOM()
1019 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 780 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
1020 // If cached was not found (or not usable), then read the database and build the response: 781 // If cached was not found (or not usable), then read the database and build the response:
1021 782
1022 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 783 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
1023 784
1024 785
1025 // Optionally filter the results: 786 // Optionally filter the results:
@@ -1104,7 +865,7 @@ function showDailyRSS()
1104 $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn()); 865 $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn());
1105 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 866 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
1106 // If cached was not found (or not usable), then read the database and build the response: 867 // If cached was not found (or not usable), then read the database and build the response:
1107 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 868 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
1108 869
1109 /* Some Shaarlies may have very few links, so we need to look 870 /* Some Shaarlies may have very few links, so we need to look
1110 back in time (rsort()) until we have enough days ($nb_of_days). 871 back in time (rsort()) until we have enough days ($nb_of_days).
@@ -1172,7 +933,7 @@ function showDailyRSS()
1172// "Daily" page. 933// "Daily" page.
1173function showDaily() 934function showDaily()
1174{ 935{
1175 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 936 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
1176 937
1177 938
1178 $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD. 939 $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD.
@@ -1240,7 +1001,7 @@ function showDaily()
1240// Render HTML page (according to URL parameters and user rights) 1001// Render HTML page (according to URL parameters and user rights)
1241function renderPage() 1002function renderPage()
1242{ 1003{
1243 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 1004 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
1244 1005
1245 // -------- Display login form. 1006 // -------- Display login form.
1246 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login')) 1007 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login'))
@@ -1822,7 +1583,7 @@ HTML;
1822function importFile() 1583function importFile()
1823{ 1584{
1824 if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); } 1585 if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); }
1825 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 1586 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
1826 $filename=$_FILES['filetoupload']['name']; 1587 $filename=$_FILES['filetoupload']['name'];
1827 $filesize=$_FILES['filetoupload']['size']; 1588 $filesize=$_FILES['filetoupload']['size'];
1828 $data=file_get_contents($_FILES['filetoupload']['tmp_name']); 1589 $data=file_get_contents($_FILES['filetoupload']['tmp_name']);
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 00000000..d6e01c35
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,15 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<phpunit
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.5/phpunit.xsd"
5 colors="true">
6 <filter>
7 <whitelist addUncoveredFilesFromWhitelist="true">
8 <directory suffix=".php">application</directory>
9 </whitelist>
10 </filter>
11 <logging>
12 <log type="coverage-html" target="coverage" lowUpperBound="30" highLowerBound="80"/>
13 <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
14 </logging>
15</phpunit>
diff --git a/tests/.htaccess b/tests/.htaccess
new file mode 100644
index 00000000..b584d98c
--- /dev/null
+++ b/tests/.htaccess
@@ -0,0 +1,2 @@
1Allow from none
2Deny from all
diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php
new file mode 100644
index 00000000..bbe4e026
--- /dev/null
+++ b/tests/LinkDBTest.php
@@ -0,0 +1,509 @@
1<?php
2/**
3 * Link datastore tests
4 */
5
6require_once 'application/LinkDB.php';
7require_once 'application/Utils.php';
8require_once 'tests/utils/ReferenceLinkDB.php';
9
10define('PHPPREFIX', '<?php /* ');
11define('PHPSUFFIX', ' */ ?>');
12
13
14/**
15 * Unitary tests for LinkDB
16 */
17class LinkDBTest extends PHPUnit_Framework_TestCase
18{
19 // datastore to test write operations
20 protected static $testDatastore = 'tests/datastore.php';
21 protected static $dummyDatastoreSHA1 = 'e3edea8ea7bb50be4bcb404df53fbb4546a7156e';
22 protected static $refDB = null;
23 protected static $publicLinkDB = null;
24 protected static $privateLinkDB = null;
25
26 /**
27 * Instantiates public and private LinkDBs with test data
28 *
29 * The reference datastore contains public and private links that
30 * will be used to test LinkDB's methods:
31 * - access filtering (public/private),
32 * - link searches:
33 * - by day,
34 * - by tag,
35 * - by text,
36 * - etc.
37 */
38 public static function setUpBeforeClass()
39 {
40 self::$refDB = new ReferenceLinkDB();
41 self::$refDB->write(self::$testDatastore, PHPPREFIX, PHPSUFFIX);
42
43 $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
44 self::$publicLinkDB = new LinkDB(false);
45 self::$privateLinkDB = new LinkDB(true);
46 }
47
48 /**
49 * Resets test data for each test
50 */
51 protected function setUp()
52 {
53 $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
54 if (file_exists(self::$testDatastore)) {
55 unlink(self::$testDatastore);
56 }
57 }
58
59 /**
60 * Allows to test LinkDB's private methods
61 *
62 * @see
63 * https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html
64 * http://stackoverflow.com/a/2798203
65 */
66 protected static function getMethod($name)
67 {
68 $class = new ReflectionClass('LinkDB');
69 $method = $class->getMethod($name);
70 $method->setAccessible(true);
71 return $method;
72 }
73
74 /**
75 * Instantiate LinkDB objects - logged in user
76 */
77 public function testConstructLoggedIn()
78 {
79 new LinkDB(true);
80 $this->assertFileExists(self::$testDatastore);
81 }
82
83 /**
84 * Instantiate LinkDB objects - logged out or public instance
85 */
86 public function testConstructLoggedOut()
87 {
88 new LinkDB(false);
89 $this->assertFileExists(self::$testDatastore);
90 }
91
92 /**
93 * Attempt to instantiate a LinkDB whereas the datastore is not writable
94 *
95 * @expectedException PHPUnit_Framework_Error_Warning
96 * @expectedExceptionMessageRegExp /failed to open stream: No such file or directory/
97 */
98 public function testConstructDatastoreNotWriteable()
99 {
100 $GLOBALS['config']['DATASTORE'] = 'null/store.db';
101 new LinkDB(false);
102 }
103
104 /**
105 * The DB doesn't exist, ensure it is created with dummy content
106 */
107 public function testCheckDBNew()
108 {
109 $linkDB = new LinkDB(false);
110 unlink(self::$testDatastore);
111 $this->assertFileNotExists(self::$testDatastore);
112
113 $checkDB = self::getMethod('checkDB');
114 $checkDB->invokeArgs($linkDB, array());
115 $this->assertFileExists(self::$testDatastore);
116
117 // ensure the correct data has been written
118 $this->assertEquals(
119 self::$dummyDatastoreSHA1,
120 sha1_file(self::$testDatastore)
121 );
122 }
123
124 /**
125 * The DB exists, don't do anything
126 */
127 public function testCheckDBLoad()
128 {
129 $linkDB = new LinkDB(false);
130 $this->assertEquals(
131 self::$dummyDatastoreSHA1,
132 sha1_file(self::$testDatastore)
133 );
134
135 $checkDB = self::getMethod('checkDB');
136 $checkDB->invokeArgs($linkDB, array());
137
138 // ensure the datastore is left unmodified
139 $this->assertEquals(
140 self::$dummyDatastoreSHA1,
141 sha1_file(self::$testDatastore)
142 );
143 }
144
145 /**
146 * Load an empty DB
147 */
148 public function testReadEmptyDB()
149 {
150 file_put_contents(self::$testDatastore, PHPPREFIX.'S7QysKquBQA='.PHPSUFFIX);
151 $emptyDB = new LinkDB(false);
152 $this->assertEquals(0, sizeof($emptyDB));
153 $this->assertEquals(0, count($emptyDB));
154 }
155
156 /**
157 * Load public links from the DB
158 */
159 public function testReadPublicDB()
160 {
161 $this->assertEquals(
162 self::$refDB->countPublicLinks(),
163 sizeof(self::$publicLinkDB)
164 );
165 }
166
167 /**
168 * Load public and private links from the DB
169 */
170 public function testReadPrivateDB()
171 {
172 $this->assertEquals(
173 self::$refDB->countLinks(),
174 sizeof(self::$privateLinkDB)
175 );
176 }
177
178 /**
179 * Save the links to the DB
180 */
181 public function testSaveDB()
182 {
183 $testDB = new LinkDB(true);
184 $dbSize = sizeof($testDB);
185
186 $link = array(
187 'title'=>'an additional link',
188 'url'=>'http://dum.my',
189 'description'=>'One more',
190 'private'=>0,
191 'linkdate'=>'20150518_190000',
192 'tags'=>'unit test'
193 );
194 $testDB[$link['linkdate']] = $link;
195
196 // TODO: move PageCache to a proper class/file
197 function invalidateCaches() {}
198
199 $testDB->savedb();
200
201 $testDB = new LinkDB(true);
202 $this->assertEquals($dbSize + 1, sizeof($testDB));
203 }
204
205 /**
206 * Count existing links
207 */
208 public function testCount()
209 {
210 $this->assertEquals(
211 self::$refDB->countPublicLinks(),
212 self::$publicLinkDB->count()
213 );
214 $this->assertEquals(
215 self::$refDB->countLinks(),
216 self::$privateLinkDB->count()
217 );
218 }
219
220 /**
221 * List the days for which links have been posted
222 */
223 public function testDays()
224 {
225 $this->assertEquals(
226 ['20121206', '20130614', '20150310'],
227 self::$publicLinkDB->days()
228 );
229
230 $this->assertEquals(
231 ['20121206', '20130614', '20141125', '20150310'],
232 self::$privateLinkDB->days()
233 );
234 }
235
236 /**
237 * The URL corresponds to an existing entry in the DB
238 */
239 public function testGetKnownLinkFromURL()
240 {
241 $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/');
242
243 $this->assertNotEquals(false, $link);
244 $this->assertEquals(
245 'A free software media publishing platform',
246 $link['description']
247 );
248 }
249
250 /**
251 * The URL is not in the DB
252 */
253 public function testGetUnknownLinkFromURL()
254 {
255 $this->assertEquals(
256 false,
257 self::$publicLinkDB->getLinkFromUrl('http://dev.null')
258 );
259 }
260
261 /**
262 * Lists all tags
263 */
264 public function testAllTags()
265 {
266 $this->assertEquals(
267 [
268 'web' => 3,
269 'cartoon' => 2,
270 'gnu' => 2,
271 'dev' => 1,
272 'samba' => 1,
273 'media' => 1,
274 'software' => 1,
275 'stallman' => 1,
276 'free' => 1
277 ],
278 self::$publicLinkDB->allTags()
279 );
280
281 $this->assertEquals(
282 [
283 'web' => 4,
284 'cartoon' => 3,
285 'gnu' => 2,
286 'dev' => 2,
287 'samba' => 1,
288 'media' => 1,
289 'software' => 1,
290 'stallman' => 1,
291 'free' => 1,
292 'html' => 1,
293 'w3c' => 1,
294 'css' => 1,
295 'Mercurial' => 1
296 ],
297 self::$privateLinkDB->allTags()
298 );
299 }
300
301 /**
302 * Filter links using a tag
303 */
304 public function testFilterOneTag()
305 {
306 $this->assertEquals(
307 3,
308 sizeof(self::$publicLinkDB->filterTags('web', false))
309 );
310
311 $this->assertEquals(
312 4,
313 sizeof(self::$privateLinkDB->filterTags('web', false))
314 );
315 }
316
317 /**
318 * Filter links using a tag - case-sensitive
319 */
320 public function testFilterCaseSensitiveTag()
321 {
322 $this->assertEquals(
323 0,
324 sizeof(self::$privateLinkDB->filterTags('mercurial', true))
325 );
326
327 $this->assertEquals(
328 1,
329 sizeof(self::$privateLinkDB->filterTags('Mercurial', true))
330 );
331 }
332
333 /**
334 * Filter links using a tag combination
335 */
336 public function testFilterMultipleTags()
337 {
338 $this->assertEquals(
339 1,
340 sizeof(self::$publicLinkDB->filterTags('dev cartoon', false))
341 );
342
343 $this->assertEquals(
344 2,
345 sizeof(self::$privateLinkDB->filterTags('dev cartoon', false))
346 );
347 }
348
349 /**
350 * Filter links using a non-existent tag
351 */
352 public function testFilterUnknownTag()
353 {
354 $this->assertEquals(
355 0,
356 sizeof(self::$publicLinkDB->filterTags('null', false))
357 );
358 }
359
360 /**
361 * Return links for a given day
362 */
363 public function testFilterDay()
364 {
365 $this->assertEquals(
366 2,
367 sizeof(self::$publicLinkDB->filterDay('20121206'))
368 );
369
370 $this->assertEquals(
371 3,
372 sizeof(self::$privateLinkDB->filterDay('20121206'))
373 );
374 }
375
376 /**
377 * 404 - day not found
378 */
379 public function testFilterUnknownDay()
380 {
381 $this->assertEquals(
382 0,
383 sizeof(self::$publicLinkDB->filterDay('19700101'))
384 );
385
386 $this->assertEquals(
387 0,
388 sizeof(self::$privateLinkDB->filterDay('19700101'))
389 );
390 }
391
392 /**
393 * Use an invalid date format
394 */
395 public function testFilterInvalidDay()
396 {
397 $this->assertEquals(
398 0,
399 sizeof(self::$privateLinkDB->filterDay('Rainy day, dream away'))
400 );
401
402 // TODO: check input format
403 $this->assertEquals(
404 6,
405 sizeof(self::$privateLinkDB->filterDay('20'))
406 );
407 }
408
409 /**
410 * Retrieve a link entry with its hash
411 */
412 public function testFilterSmallHash()
413 {
414 $links = self::$privateLinkDB->filterSmallHash('IuWvgA');
415
416 $this->assertEquals(
417 1,
418 sizeof($links)
419 );
420
421 $this->assertEquals(
422 'MediaGoblin',
423 $links['20130614_184135']['title']
424 );
425
426 }
427
428 /**
429 * No link for this hash
430 */
431 public function testFilterUnknownSmallHash()
432 {
433 $this->assertEquals(
434 0,
435 sizeof(self::$privateLinkDB->filterSmallHash('Iblaah'))
436 );
437 }
438
439 /**
440 * Full-text search - result from a link's URL
441 */
442 public function testFilterFullTextURL()
443 {
444 $this->assertEquals(
445 2,
446 sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org'))
447 );
448 }
449
450 /**
451 * Full-text search - result from a link's title only
452 */
453 public function testFilterFullTextTitle()
454 {
455 // use miscellaneous cases
456 $this->assertEquals(
457 2,
458 sizeof(self::$publicLinkDB->filterFullText('userfriendly -'))
459 );
460 $this->assertEquals(
461 2,
462 sizeof(self::$publicLinkDB->filterFullText('UserFriendly -'))
463 );
464 $this->assertEquals(
465 2,
466 sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -'))
467 );
468
469 // use miscellaneous case and offset
470 $this->assertEquals(
471 2,
472 sizeof(self::$publicLinkDB->filterFullText('RFrIendL'))
473 );
474 }
475
476 /**
477 * Full-text search - result from the link's description only
478 */
479 public function testFilterFullTextDescription()
480 {
481 $this->assertEquals(
482 1,
483 sizeof(self::$publicLinkDB->filterFullText('media publishing'))
484 );
485 }
486
487 /**
488 * Full-text search - result from the link's tags only
489 */
490 public function testFilterFullTextTags()
491 {
492 $this->assertEquals(
493 2,
494 sizeof(self::$publicLinkDB->filterFullText('gnu'))
495 );
496 }
497
498 /**
499 * Full-text search - result set from mixed sources
500 */
501 public function testFilterFullTextMixed()
502 {
503 $this->assertEquals(
504 2,
505 sizeof(self::$publicLinkDB->filterFullText('free software'))
506 );
507 }
508}
509?>
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
new file mode 100644
index 00000000..bbba99f2
--- /dev/null
+++ b/tests/UtilsTest.php
@@ -0,0 +1,78 @@
1<?php
2/**
3 * Utilities' tests
4 */
5
6require_once 'application/Utils.php';
7
8/**
9 * Unitary tests for Shaarli utilities
10 */
11class UtilsTest extends PHPUnit_Framework_TestCase
12{
13 /**
14 * Represent a link by its hash
15 */
16 public function testSmallHash()
17 {
18 $this->assertEquals('CyAAJw', smallHash('http://test.io'));
19 $this->assertEquals(6, strlen(smallHash('https://github.com')));
20 }
21
22 /**
23 * Look for a substring at the beginning of a string
24 */
25 public function testStartsWithCaseInsensitive()
26 {
27 $this->assertTrue(startsWith('Lorem ipsum', 'lorem', false));
28 $this->assertTrue(startsWith('Lorem ipsum', 'LoReM i', false));
29 }
30
31 /**
32 * Look for a substring at the beginning of a string (case-sensitive)
33 */
34 public function testStartsWithCaseSensitive()
35 {
36 $this->assertTrue(startsWith('Lorem ipsum', 'Lorem', true));
37 $this->assertFalse(startsWith('Lorem ipsum', 'lorem', true));
38 $this->assertFalse(startsWith('Lorem ipsum', 'LoReM i', true));
39 }
40
41 /**
42 * Look for a substring at the beginning of a string (Unicode)
43 */
44 public function testStartsWithSpecialChars()
45 {
46 $this->assertTrue(startsWith('å!ùµ', 'å!', false));
47 $this->assertTrue(startsWith('µ$åù', 'µ$', true));
48 }
49
50 /**
51 * Look for a substring at the end of a string
52 */
53 public function testEndsWithCaseInsensitive()
54 {
55 $this->assertTrue(endsWith('Lorem ipsum', 'ipsum', false));
56 $this->assertTrue(endsWith('Lorem ipsum', 'm IpsUM', false));
57 }
58
59 /**
60 * Look for a substring at the end of a string (case-sensitive)
61 */
62 public function testEndsWithCaseSensitive()
63 {
64 $this->assertTrue(endsWith('lorem Ipsum', 'Ipsum', true));
65 $this->assertFalse(endsWith('lorem Ipsum', 'ipsum', true));
66 $this->assertFalse(endsWith('lorem Ipsum', 'M IPsuM', true));
67 }
68
69 /**
70 * Look for a substring at the end of a string (Unicode)
71 */
72 public function testEndsWithSpecialChars()
73 {
74 $this->assertTrue(endsWith('å!ùµ', 'ùµ', false));
75 $this->assertTrue(endsWith('µ$åù', 'åù', true));
76 }
77}
78?>
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php
new file mode 100644
index 00000000..2cb05bae
--- /dev/null
+++ b/tests/utils/ReferenceLinkDB.php
@@ -0,0 +1,128 @@
1<?php
2/**
3 * Populates a reference datastore to test LinkDB
4 */
5class ReferenceLinkDB
6{
7 private $links = array();
8 private $publicCount = 0;
9 private $privateCount = 0;
10
11 /**
12 * Populates the test DB with reference data
13 */
14 function __construct()
15 {
16 $this->addLink(
17 'Free as in Freedom 2.0',
18 'https://static.fsf.org/nosvn/faif-2.0.pdf',
19 'Richard Stallman and the Free Software Revolution',
20 0,
21 '20150310_114633',
22 'free gnu software stallman'
23 );
24
25 $this->addLink(
26 'MediaGoblin',
27 'http://mediagoblin.org/',
28 'A free software media publishing platform',
29 0,
30 '20130614_184135',
31 'gnu media web'
32 );
33
34 $this->addLink(
35 'w3c-markup-validator',
36 'https://dvcs.w3.org/hg/markup-validator/summary',
37 'Mercurial repository for the W3C Validator',
38 1,
39 '20141125_084734',
40 'css html w3c web Mercurial'
41 );
42
43 $this->addLink(
44 'UserFriendly - Web Designer',
45 'http://ars.userfriendly.org/cartoons/?id=20121206',
46 'Naming conventions...',
47 0,
48 '20121206_142300',
49 'dev cartoon web'
50 );
51
52 $this->addLink(
53 'UserFriendly - Samba',
54 'http://ars.userfriendly.org/cartoons/?id=20010306',
55 'Tropical printing',
56 0,
57 '20121206_172539',
58 'samba cartoon web'
59 );
60
61 $this->addLink(
62 'Geek and Poke',
63 'http://geek-and-poke.com/',
64 '',
65 1,
66 '20121206_182539',
67 'dev cartoon'
68 );
69 }
70
71 /**
72 * Adds a new link
73 */
74 protected function addLink($title, $url, $description, $private, $date, $tags)
75 {
76 $link = array(
77 'title' => $title,
78 'url' => $url,
79 'description' => $description,
80 'private' => $private,
81 'linkdate' => $date,
82 'tags' => $tags,
83 );
84 $this->links[$date] = $link;
85
86 if ($private) {
87 $this->privateCount++;
88 return;
89 }
90 $this->publicCount++;
91 }
92
93 /**
94 * Writes data to the datastore
95 */
96 public function write($filename, $prefix, $suffix)
97 {
98 file_put_contents(
99 $filename,
100 $prefix.base64_encode(gzdeflate(serialize($this->links))).$suffix
101 );
102 }
103
104 /**
105 * Returns the number of links in the reference data
106 */
107 public function countLinks()
108 {
109 return $this->publicCount + $this->privateCount;
110 }
111
112 /**
113 * Returns the number of public links in the reference data
114 */
115 public function countPublicLinks()
116 {
117 return $this->publicCount;
118 }
119
120 /**
121 * Returns the number of private links in the reference data
122 */
123 public function countPrivateLinks()
124 {
125 return $this->privateCount;
126 }
127}
128?>