diff --git a/build.xml b/build.xml index 32f5a6cc..a2912bdb 100644 --- a/build.xml +++ b/build.xml @@ -8,6 +8,7 @@ + diff --git a/composer.json b/composer.json index 9d98159b..5ac1e731 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,8 @@ } ], + "minimum-stability": "dev", + "prefer-stable": true, "require": { "php": "^7.0", "ext-intl": "*", @@ -24,7 +26,9 @@ "ext-hash": "*", "fguillot/picofeed": ">=0.1.31", "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0" + "docopt/docopt": "^1.0", + "jkingweb/druuid": "^3.0", + "phpseclib/phpseclib": "^2.0" }, "require-dev": { "mikey179/vfsStream": "^1.6", @@ -34,7 +38,8 @@ "phpdocumentor/phpdocumentor": "2.*", "friendsofphp/php-cs-fixer": "^2.2", "phing/phing": "^2.16", - "pear/archive_tar": "*" + "pear/archive_tar": "*", + "johnkary/phpunit-speedtrap": "^2.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 3cd05e41..d6e1294f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "125797db6f29f530c2f89209cc4f462d", + "content-hash": "2a8e077ce9d05d304c9041be28d1154e", "packages": [ { "name": "docopt/docopt", @@ -54,16 +54,16 @@ }, { "name": "fguillot/picofeed", - "version": "v0.1.35", + "version": "v0.1.37", "source": { "type": "git", "url": "https://github.com/miniflux/picoFeed.git", - "reference": "3a27b47de31eedec075c719f961783c5db7a7b08" + "reference": "402b7f07629577e7929625e78bc88d3d5831a22d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/miniflux/picoFeed/zipball/3a27b47de31eedec075c719f961783c5db7a7b08", - "reference": "3a27b47de31eedec075c719f961783c5db7a7b08", + "url": "https://api.github.com/repos/miniflux/picoFeed/zipball/402b7f07629577e7929625e78bc88d3d5831a22d", + "reference": "402b7f07629577e7929625e78bc88d3d5831a22d", "shasum": "" }, "require": { @@ -103,7 +103,7 @@ ], "description": "Modern library to handle RSS/Atom feeds", "homepage": "https://github.com/miniflux/picoFeed", - "time": "2017-06-20T22:54:47+00:00" + "time": "2017-11-02T03:20:36+00:00" }, { "name": "hosteurope/password-generator", @@ -145,6 +145,143 @@ "description": "Password generator for generating policy-compliant passwords.", "time": "2016-12-08T09:32:12+00:00" }, + { + "name": "jkingweb/druuid", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/JKingweb/DrUUID.git", + "reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JKingweb/DrUUID/zipball/ca88019069f03ee9c0b1bb6b0200f421bbc9607e", + "reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "suggest": { + "ext-bcmath": "Supported alternative to GMP on 32-bit systems", + "ext-gmp": "Recommended on 32-bit installations for time-base UUIDs", + "phpseclib/phpseclib": "Supported alternative to GMP or BC Math on 32-bit systems (either v1.x or v2.x)" + }, + "type": "library", + "autoload": { + "psr-4": { + "JKingWeb\\DrUUID\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "J. King", + "email": "jking@jkingweb.ca", + "homepage": "https://jkingweb.ca/" + } + ], + "description": "DrUUID RFC 4122 library for PHP", + "keywords": [ + "uuid" + ], + "time": "2017-02-09T14:17:01+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b", + "reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "~4.0", + "sami/sami": "~2.0", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "time": "2017-10-23T05:04:54+00:00" + }, { "name": "zendframework/zendxml", "version": "1.0.2", @@ -623,16 +760,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.2.8", + "version": "v2.2.9", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2" + "reference": "eace538b022a2b7db59ef7b5460cb8c66cb20b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/aca23e791784eade7b377d578d6dfc6fcf1398d2", - "reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/eace538b022a2b7db59ef7b5460cb8c66cb20b50", + "reference": "eace538b022a2b7db59ef7b5460cb8c66cb20b50", "shasum": "" }, "require": { @@ -643,17 +780,17 @@ "gecko-packages/gecko-php-unit": "^2.0", "php": "^5.3.6 || >=7.0 <7.3", "sebastian/diff": "^1.4", - "symfony/console": "^2.4 || ^3.0", - "symfony/event-dispatcher": "^2.1 || ^3.0", - "symfony/filesystem": "^2.4 || ^3.0", - "symfony/finder": "^2.2 || ^3.0", - "symfony/options-resolver": "^2.6 || ^3.0", + "symfony/console": "^2.4 || ^3.0 || ^4.0", + "symfony/event-dispatcher": "^2.1 || ^3.0 || ^4.0", + "symfony/filesystem": "^2.4 || ^3.0 || ^4.0", + "symfony/finder": "^2.2 || ^3.0 || ^4.0", + "symfony/options-resolver": "^2.6 || ^3.0 || ^4.0", "symfony/polyfill-php54": "^1.0", "symfony/polyfill-php55": "^1.3", "symfony/polyfill-php70": "^1.0", "symfony/polyfill-php72": "^1.4", - "symfony/process": "^2.3 || ^3.0", - "symfony/stopwatch": "^2.5 || ^3.0" + "symfony/process": "^2.3 || ^3.0 || ^4.0", + "symfony/stopwatch": "^2.5 || ^3.0 || ^4.0" }, "conflict": { "hhvm": "<3.18" @@ -661,9 +798,9 @@ "require-dev": { "johnkary/phpunit-speedtrap": "^1.0.1", "justinrainbow/json-schema": "^5.0", + "php-coveralls/php-coveralls": "^1.0.2", "phpunit/phpunit": "^4.8.35 || ^5.4.3", - "satooshi/php-coveralls": "^1.0", - "symfony/phpunit-bridge": "^3.2.2" + "symfony/phpunit-bridge": "^3.2.2 || ^4.0" }, "suggest": { "ext-mbstring": "For handling non-UTF8 characters in cache signature.", @@ -704,7 +841,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2017-09-29T15:07:49+00:00" + "time": "2017-11-02T12:46:49+00:00" }, { "name": "gecko-packages/gecko-php-unit", @@ -999,16 +1136,16 @@ }, { "name": "jms/serializer", - "version": "1.9.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/schmittjoh/serializer.git", - "reference": "f4683f41ebf21e60667447bb49939bee35807c3c" + "reference": "e708d6ef549044974b60a57fdcec2fa165436d57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/f4683f41ebf21e60667447bb49939bee35807c3c", - "reference": "f4683f41ebf21e60667447bb49939bee35807c3c", + "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/e708d6ef549044974b60a57fdcec2fa165436d57", + "reference": "e708d6ef549044974b60a57fdcec2fa165436d57", "shasum": "" }, "require": { @@ -1078,7 +1215,55 @@ "serialization", "xml" ], - "time": "2017-09-28T15:17:28+00:00" + "time": "2017-10-27T07:15:54+00:00" + }, + { + "name": "johnkary/phpunit-speedtrap", + "version": "v2.0.0-BETA1", + "source": { + "type": "git", + "url": "https://github.com/johnkary/phpunit-speedtrap.git", + "reference": "cbd785f67116c581f71705342cb316631e5a2be9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/johnkary/phpunit-speedtrap/zipball/cbd785f67116c581f71705342cb316631e5a2be9", + "reference": "cbd785f67116c581f71705342cb316631e5a2be9", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "JohnKary\\PHPUnit\\Listener\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Kary", + "email": "john@johnkary.net" + } + ], + "description": "Find slow tests in your PHPUnit test suite", + "homepage": "https://github.com/johnkary/phpunit-speedtrap", + "keywords": [ + "phpunit", + "profile", + "slow" + ], + "time": "2017-03-17T12:23:15+00:00" }, { "name": "justinrainbow/json-schema", @@ -1315,37 +1500,40 @@ }, { "name": "myclabs/deep-copy", - "version": "1.6.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102" + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8e6e04167378abf1ddb4d3522d8755c5fd90d102", - "reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": "^5.6 || ^7.0" }, "require-dev": { - "doctrine/collections": "1.*", - "phpunit/phpunit": "~4.1" + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^4.1" }, "type": "library", "autoload": { "psr-4": { "DeepCopy\\": "src/DeepCopy/" - } + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "Create deep copies (clones) of your objects", - "homepage": "https://github.com/myclabs/DeepCopy", "keywords": [ "clone", "copy", @@ -1353,7 +1541,7 @@ "object", "object graph" ], - "time": "2017-04-12T18:52:22+00:00" + "time": "2017-10-19T19:58:43+00:00" }, { "name": "nikic/php-parser", @@ -2352,16 +2540,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "5.2.2", + "version": "5.2.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "8ed1902a57849e117b5651fc1a5c48110946c06b" + "reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8ed1902a57849e117b5651fc1a5c48110946c06b", - "reference": "8ed1902a57849e117b5651fc1a5c48110946c06b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d", + "reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d", "shasum": "" }, "require": { @@ -2370,7 +2558,7 @@ "php": "^7.0", "phpunit/php-file-iterator": "^1.4.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^1.4.11 || ^2.0", + "phpunit/php-token-stream": "^2.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", "sebastian/environment": "^3.0", "sebastian/version": "^2.0.1", @@ -2412,7 +2600,7 @@ "testing", "xunit" ], - "time": "2017-08-03T12:40:43+00:00" + "time": "2017-11-03T13:47:33+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5402,9 +5590,9 @@ } ], "aliases": [], - "minimum-stability": "stable", + "minimum-stability": "dev", "stability-flags": [], - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^7.0", diff --git a/dist/nginx.conf b/dist/nginx.conf index 160eec93..79005677 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -23,6 +23,7 @@ server { include /usr/share/arsse/dist/nginx-fcgi.conf; } + # NextCloud News protocol location /index.php/apps/news/api { try_files $uri @arsse_auth; @@ -30,4 +31,20 @@ server { try_files $uri @arsse_no_auth; } } + + # Tiny Tiny RSS protocol + location /tt-rss/api { + try_files $uri @arsse_no_auth; + } + + # Tiny Tiny RSS feed icons + location /tt-rss/feed-icons/ { + try_files $uri @arsse_no_auth; + } + + # Tiny Tiny RSS special-feed icons + location /tt-rss/images/ { + root /usr/share/arsse/www; + try_files $uri =404; + } } \ No newline at end of file diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 9983dc82..0806a135 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -7,9 +7,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse; abstract class AbstractException extends \Exception { - const CODES = [ + const CODES = [ "Exception.uncoded" => -1, "Exception.unknown" => 10000, + "Exception.constantUnknown" => 10001, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, @@ -40,7 +41,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointStatusUnknown" => 10225, "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, - "Db/Exception.resultReused" => 10227, + "Db/Exception.resultReused" => 10228, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, @@ -65,6 +66,7 @@ abstract class AbstractException extends \Exception { "User/Exception.authMissing" => 10411, "User/Exception.authFailed" => 10412, "User/ExceptionAuthz.notAuthorized" => 10421, + "User/ExceptionSession.invalid" => 10431, "Feed/Exception.invalidCertificate" => 10501, "Feed/Exception.invalidUrl" => 10502, "Feed/Exception.maxRedirect" => 10503, diff --git a/lib/Conf.php b/lib/Conf.php index 50b419c5..6e5903e1 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -32,10 +32,16 @@ class Conf { public $userPreAuth = false; /** @var integer Desired length of temporary user passwords */ public $userTempPasswordLength = 20; + /** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 1 hour) + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ + public $userSessionTimeout = "PT1H"; + /** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 24 hours); + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ + public $userSessionLifetime = "PT24H"; /** @var string Class of the background feed update service driver in use (Forking by default) */ public $serviceDriver = Service\Forking\Driver::class; - /** @var string The interval between checks for new feeds, as an ISO 8601 duration + /** @var string The interval between checks for new articles, as an ISO 8601 duration * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $serviceFrequency = "PT2M"; /** @var integer Number of concurrent feed updates to perform */ diff --git a/lib/Database.php b/lib/Database.php index 08eea6ab..e7f33cf1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -7,13 +7,20 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; +use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Database { - const SCHEMA_VERSION = 1; + const SCHEMA_VERSION = 2; + const LIMIT_ARTICLES = 50; + // articleList verbosity levels + const LIST_MINIMAL = 0; // only that metadata which is required for context matching + const LIST_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text + const LIST_TYPICAL = 2; // conservative, with the addition of content + const LIST_FULL = 3; // all possible fields /** @var Db\Driver */ public $db; @@ -207,6 +214,10 @@ class Database { "name" => "str", ]; list($setClause, $setTypes, $setValues) = $this->generateSet($properties, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return $this->userPropertiesGet($user); + } $this->db->prepare("UPDATE arsse_users set $setClause where id is ?", $setTypes, "str")->run($setValues, $user); return $this->userPropertiesGet($user); } @@ -228,6 +239,58 @@ class Database { return true; } + public function sessionCreate(string $user): string { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // generate a new session ID and expiry date + $id = UUID::mint()->hex; + $expires = Date::add(Arsse::$conf->userSessionTimeout); + // save the session to the database + $this->db->prepare("INSERT INTO arsse_sessions(id,expires,user) values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user); + // return the ID + return $id; + } + + public function sessionDestroy(string $user, string $id): bool { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // delete the session and report success. + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id is ? and user is ?", "str", "str")->run($id, $user)->changes(); + } + + public function sessionResume(string $id): array { + $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); + $out = $this->db->prepare("SELECT id,created,expires,user from arsse_sessions where id is ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); + // if the session does not exist or is expired, throw an exception + if (!$out) { + throw new User\ExceptionSession("invalid", $id); + } + // if we're more than half-way from the session expiring, renew it + if ($this->sessionExpiringSoon(Date::normalize($out['expires'], "sql"))) { + $expires = Date::add(Arsse::$conf->userSessionTimeout); + $this->db->prepare("UPDATE arsse_sessions set expires = ? where id is ?", "datetime", "str")->run($expires, $id); + } + return $out; + } + + public function sessionCleanup(): int { + $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); + return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes(); + } + + protected function sessionExpiringSoon(\DateTimeInterface $expiry): bool { + // calculate half the session timeout as a number of seconds + $now = time(); + $max = Date::add(Arsse::$conf->userSessionTimeout, $now)->getTimestamp(); + $diff = intdiv($max - $now, 2); + // determine if the expiry time is less than half the session timeout into the future + return (($now + $diff) >= $expiry->getTimestamp()); + } + public function folderAdd(string $user, array $data): int { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -249,15 +312,22 @@ class Database { } // check to make sure the parent exists, if one is specified $parent = $this->folderValidateId($user, $parent)['id']; - // if we're not returning a recursive list we can use a simpler query + $q = new Query( + "SELECT + id,name,parent, + (select count(*) from arsse_folders as parents where parents.parent is arsse_folders.id) as children, + (select count(*) from arsse_subscriptions where folder is arsse_folders.id) as feeds + FROM arsse_folders" + ); if (!$recursive) { - return $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and parent is ?", "str", "int")->run($user, $parent); + $q->setWhere("owner is ?", "str", $user); + $q->setWhere("parent is ?", "int", $parent); } else { - return $this->db->prepare( - "WITH RECURSIVE folders(id) as (SELECT id from arsse_folders where owner is ? and parent is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id) ". - "SELECT id,name,parent from arsse_folders where id in (SELECT id from folders) order by name", - "str", "int")->run($user, $parent); + $q->setCTE("folders", "SELECT id from arsse_folders where owner is ? and parent is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "int"], [$user, $parent]); + $q->setWhere("id in (SELECT id from folders)"); } + $q->setOrder("name"); + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } public function folderRemove(string $user, $id): bool { @@ -265,7 +335,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]); + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]); } $changes = $this->db->prepare("DELETE FROM arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); if (!$changes) { @@ -279,7 +349,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]); + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]); } $props = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow(); if (!$props) { @@ -313,7 +383,7 @@ class Database { // if a new parent is specified, validate it $in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent']); } else { - // if neither was specified, do nothing + // if no changes would actually be applied, just return return false; } $valid = [ @@ -438,7 +508,7 @@ class Database { return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } - public function subscriptionList(string $user, $folder = null, int $id = null): Db\Result { + public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } @@ -447,8 +517,9 @@ class Database { // create a complex query $q = new Query( "SELECT - arsse_subscriptions.id, - url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.id as id, + feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_feeds.updated as updated, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, (SELECT count(*) from arsse_articles where feed is arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription is arsse_subscriptions.id and read is 1) as unread @@ -466,13 +537,34 @@ class Database { // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // if an ID is specified, add a suitable WHERE condition and bindings $q->setWhere("arsse_subscriptions.id is ?", "int", $id); - } elseif ($folder) { + } elseif ($folder && $recursive) { + // if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder); + // add a suitable WHERE condition + $q->setWhere("folder in (select folder from folders)"); + } elseif (!$recursive) { + // if we're not listing recursively, match against only the specified folder (even if it is null) + $q->setWhere("folder is ?", "int", $folder); + } + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + } + + public function subscriptionCount(string $user, $folder = null): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate inputs + $folder = $this->folderValidateId($user, $folder)['id']; + // create a complex query + $q = new Query("SELECT count(*) from arsse_subscriptions"); + $q->setWhere("owner is ?", "str", $user); + if ($folder) { // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder); // add a suitable WHERE condition $q->setWhere("folder in (select folder from folders)"); } - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } public function subscriptionRemove(string $user, $id): bool { @@ -480,7 +572,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]); + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]); } $changes = $this->db->prepare("DELETE from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); if (!$changes) { @@ -494,11 +586,11 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]); + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]); } // disable authorization checks for the list call Arsse::$user->authorizationEnabled(false); - $sub = $this->subscriptionList($user, null, (int) $id)->getRow(); + $sub = $this->subscriptionList($user, null, true, (int) $id)->getRow(); Arsse::$user->authorizationEnabled(true); if (!$sub) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); @@ -537,14 +629,22 @@ class Database { 'pinned' => "strict bool", ]; list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); - $out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause where owner is ? and id is ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner is ? and id is ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); $tr->commit(); return $out; } + public function subscriptionFavicon(int $id): string { + return (string) $this->db->prepare("SELECT favicon from arsse_feeds join arsse_subscriptions on feed is arsse_feeds.id where arsse_subscriptions.id is ?", "int")->run($id)->getValue(); + } + protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'id' => $id, 'type' => "int > 0"]); + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]); } $out = $this->db->prepare("SELECT id,feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); if (!$out) { @@ -719,69 +819,129 @@ class Database { )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); } - public function articleList(string $user, Context $context = null): Db\Result { - if (!Arsse::$user->authorize($user, __FUNCTION__)) { - throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); - } - if (!$context) { - $context = new Context; + protected function articleQuery(string $user, Context $context, array $extraColumns = []): Query { + $extraColumns = implode(",", $extraColumns); + if (strlen($extraColumns)) { + $extraColumns .= ","; } $q = new Query( "SELECT + $extraColumns arsse_articles.id as id, - arsse_articles.url as url, - title,author,content,guid, - published as published_date, - edited as edited_date, + arsse_articles.feed as feed, + arsse_articles.modified as modified_date, max( - modified, - coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'') - ) as modified_date, + arsse_articles.modified, + coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),''), + coalesce((select modified from arsse_label_members where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'') + ) as marked_date, NOT (select count(*) from arsse_marks where article is arsse_articles.id and read is 1 and subscription in (select sub from subscribed_feeds)) as unread, (select count(*) from arsse_marks where article is arsse_articles.id and starred is 1 and subscription in (select sub from subscribed_feeds)) as starred, (select max(id) from arsse_editions where article is arsse_articles.id) as edition, - subscribed_feeds.sub as subscription, - url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint, - arsse_enclosures.url as media_url, - arsse_enclosures.type as media_type - FROM arsse_articles - join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id - left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id - " + subscribed_feeds.sub as subscription + FROM arsse_articles" ); - $q->setOrder("edition".($context->reverse ? " desc" : "")); $q->setLimit($context->limit, $context->offset); $q->setCTE("user(user)", "SELECT ?", "str", $user); if ($context->subscription()) { // if a subscription is specified, make sure it exists $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; // add a basic CTE that will join in only the requested subscription - $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]); + $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id"); } elseif ($context->folder()) { // if a folder is specified, make sure it exists $this->folderValidateId($user, $context->folder); // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $context->folder); // add another CTE for the subscriptions within the folder - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder"); + $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); + } elseif ($context->folderShallow()) { + // if a shallow folder is specified, make sure it exists + $this->folderValidateId($user, $context->folderShallow); + // if it does exist, add a CTE with only its subscriptions (and not those of its descendents) + $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner and coalesce(folder,0) is ?", "strict int", $context->folderShallow, "join subscribed_feeds on feed is subscribed_feeds.id"); } else { // otherwise add a CTE for all the user's subscriptions - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner"); + $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); + } + if ($context->edition()) { + // if an edition is specified, filter for its previously identified article + $q->setWhere("arsse_articles.id is (select article from arsse_editions where id is ?)", "int", $context->edition); + } elseif ($context->article()) { + // if an article is specified, filter for it (it has already been validated above) + $q->setWhere("arsse_articles.id is ?", "int", $context->article); + } + if ($context->editions()) { + // if multiple specific editions have been requested, prepare a CTE to list them and their articles + if (!$context->editions) { + throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { + throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + } + list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); + $q->setCTE("requested_articles(id,edition)", + "SELECT article,id as edition from arsse_editions where edition in ($inParams)", + $inTypes, + $context->editions + ); + $q->setWhere("arsse_articles.id in (select id from requested_articles)"); + } elseif ($context->articles()) { + // if multiple specific articles have been requested, prepare a CTE to list them and their articles + if (!$context->articles) { + throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { + throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + } + list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); + $q->setCTE("requested_articles(id,edition)", + "SELECT id,(select max(id) from arsse_editions where article is arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams)", + $inTypes, + $context->articles + ); + $q->setWhere("arsse_articles.id in (select id from requested_articles)"); + } else { + // if neither list is specified, mock an empty table + $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0"); + } + // filter based on label by ID or name + if ($context->labelled()) { + // any label (true) or no label (false) + $q->setWhere((!$context->labelled ? "not " : "")."exists(select article from arsse_label_members where assigned is 1 and article is arsse_articles.id and subscription in (select sub from subscribed_feeds))"); + } elseif ($context->label() || $context->labelName()) { + // specific label ID or name + if ($context->label()) { + $id = $this->labelValidateId($user, $context->label, false)['id']; + } else { + $id = $this->labelValidateId($user, $context->labelName, true)['id']; + } + $q->setWhere("exists(select article from arsse_label_members where assigned is 1 and article is arsse_articles.id and label is ?)", "int", $id); + } + // filter based on article or edition offset + if ($context->oldestArticle()) { + $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); + } + if ($context->latestArticle()) { + $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); } - // filter based on edition offset if ($context->oldestEdition()) { $q->setWhere("edition >= ?", "int", $context->oldestEdition); } if ($context->latestEdition()) { $q->setWhere("edition <= ?", "int", $context->latestEdition); } - // filter based on lastmod time + // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) if ($context->modifiedSince()) { $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); } if ($context->notModifiedSince()) { $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); } + if ($context->markedSince()) { + $q->setWhere("marked_date >= ?", "datetime", $context->markedSince); + } + if ($context->notMarkedSince()) { + $q->setWhere("marked_date <= ?", "datetime", $context->notMarkedSince); + } // filter for un/read and un/starred status if specified if ($context->unread()) { $q->setWhere("unread is ?", "bool", $context->unread); @@ -789,159 +949,235 @@ class Database { if ($context->starred()) { $q->setWhere("starred is ?", "bool", $context->starred); } - // perform the query and return results - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + // filter based on whether the article has a note + if ($context->annotated()) { + $q->setWhere((!$context->annotated ? "not " : "")."exists(select modified from arsse_marks where article is arsse_articles.id and note <> '' and subscription in (select sub from subscribed_feeds))"); + } + // return the query + return $q; } - public function articleMark(string $user, array $data, Context $context = null): bool { + protected function articleChunk(Context $context): array { + $exception = ""; + if ($context->editions()) { + // editions take precedence over articles + if (sizeof($context->editions) > self::LIMIT_ARTICLES) { + $exception = "editions"; + } + } elseif ($context->articles()) { + if (sizeof($context->articles) > self::LIMIT_ARTICLES) { + $exception = "articles"; + } + } + if ($exception) { + $out = []; + $list = array_chunk($context->$exception, self::LIMIT_ARTICLES); + foreach ($list as $chunk) { + $out[] = (clone $context)->$exception($chunk); + } + return $out; + } else { + return []; + } + } + + public function articleList(string $user, Context $context = null, int $fields = self::LIST_FULL): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - if (!$context) { - $context = new Context; - } - // sanitize input - $values = [ - isset($data['read']) ? $data['read'] : null, - isset($data['starred']) ? $data['starred'] : null, - ]; - // the two queries we want to execute to make the requested changes - $queries = [ - "UPDATE arsse_marks - set - read = case when (select honour_read from target_articles where target_articles.id is article) is 1 then (select read from target_values) else read end, - starred = coalesce((select starred from target_values),starred), - modified = CURRENT_TIMESTAMP - WHERE - subscription in (select sub from subscribed_feeds) - and article in (select id from target_articles where to_insert is 0 and (honour_read is 1 or honour_star is 1))", - "INSERT INTO arsse_marks(subscription,article,read,starred) - select - (select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed), - id, - coalesce((select read from target_values) * honour_read,0), - coalesce((select starred from target_values),0) - from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1)" - ]; - $out = 0; - // wrap this UPDATE and INSERT together into a transaction - $tr = $this->begin(); - // if an edition context is specified, make sure it's valid - if ($context->edition()) { - // make sure the edition exists - $edition = $this->articleValidateEdition($user, $context->edition); - // if the edition is not the latest, do not mark the read flag - if (!$edition['current']) { - $values[0] = null; + $context = $context ?? new Context; + // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result + if ($contexts = $this->articleChunk($context)) { + $out = []; + $tr = $this->begin(); + foreach ($contexts as $context) { + $out[] = $this->articleList($user, $context, $fields); } - } elseif ($context->article()) { - // otherwise if an article context is specified, make sure it's valid - $this->articleValidateId($user, $context->article); - } - // execute each query in sequence - foreach ($queries as $query) { - // first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles - $q = new Query( - "SELECT - arsse_articles.id as id, - feed, - (select max(id) from arsse_editions where article is arsse_articles.id) as edition, - max(arsse_articles.modified, - coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'') - ) as modified_date, - (not exists(select article from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert, - ((select read from target_values) is not null and (select read from target_values) is not (coalesce((select read from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article is arsse_articles.id) in (select edition from requested_articles))) as honour_read, - ((select starred from target_values) is not null and (select starred from target_values) is not (coalesce((select starred from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star - FROM arsse_articles" - ); - // common table expression for the affected user - $q->setCTE("user(user)", "SELECT ?", "str", $user); - // common table expression with the values to set - $q->setCTE("target_values(read,starred)", "SELECT ?,?", ["bool","bool"], $values); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; - // add a basic CTE that will join in only the requested subscription - $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id"); - } elseif ($context->folder()) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $context->folder); - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $context->folder); - // add another CTE for the subscriptions within the folder - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); - } else { - // otherwise add a CTE for all the user's subscriptions - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); + $tr->commit(); + return new Db\ResultAggregate(...$out); + } else { + $columns = []; + switch ($fields) { + // NOTE: the cases all cascade into each other: a given verbosity level is always a superset of the previous one + case self::LIST_FULL: // everything + $columns = array_merge($columns,[ + "(select note from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note", + ]); + case self::LIST_TYPICAL: // conservative, plus content + $columns = array_merge($columns,[ + "content", + "arsse_enclosures.url as media_url", // enclosures are potentially large due to data: URLs + "arsse_enclosures.type as media_type", // FIXME: enclosures should eventually have their own fetch method + ]); + case self::LIST_CONSERVATIVE: // base metadata, plus anything that is not likely to be large text + $columns = array_merge($columns,[ + "arsse_articles.url as url", + "arsse_articles.title as title", + "(select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed is arsse_feeds.id where arsse_feeds.id is arsse_articles.feed) as subscription_title", + "author", + "guid", + "published as published_date", + "edited as edited_date", + "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", + ]); + case self::LIST_MINIMAL: // base metadata (always included: required for context matching) + $columns = array_merge($columns,[ + // id, subscription, feed, modified_date, marked_date, unread, starred, edition + "edited as edited_date", + ]); + break; + default: + throw new Exception("constantUnknown", $fields); } + $q = $this->articleQuery($user, $context, $columns); + $q->setOrder("edited_date".($context->reverse ? " desc" : "")); + $q->setOrder("edition".($context->reverse ? " desc" : "")); + $q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id"); + // perform the query and return results + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + } + } + + public function articleCount(string $user, Context $context = null): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $context = $context ?? new Context; + // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result + if ($contexts = $this->articleChunk($context)) { + $out = 0; + $tr = $this->begin(); + foreach ($contexts as $context) { + $out += $this->articleCount($user, $context); + } + $tr->commit(); + return $out; + } else { + $q = $this->articleQuery($user, $context); + $q->pushCTE("selected_articles"); + $q->setBody("SELECT count(*) from selected_articles"); + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + } + } + + public function articleMark(string $user, array $data, Context $context = null): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $context = $context ?? new Context; + // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result + if ($contexts = $this->articleChunk($context)) { + $out = 0; + $tr = $this->begin(); + foreach ($contexts as $context) { + $out += $this->articleMark($user, $data, $context); + } + $tr->commit(); + return $out; + } else { + // sanitize input + $values = [ + isset($data['read']) ? $data['read'] : null, + isset($data['starred']) ? $data['starred'] : null, + isset($data['note']) ? $data['note'] : null, + ]; + // the two queries we want to execute to make the requested changes + $queries = [ + "UPDATE arsse_marks + set + read = case when (select honour_read from target_articles where target_articles.id is article) is 1 then (select read from target_values) else read end, + starred = coalesce((select starred from target_values),starred), + note = coalesce((select note from target_values),note), + modified = CURRENT_TIMESTAMP + WHERE + subscription in (select sub from subscribed_feeds) + and article in (select id from target_articles where to_insert is 0 and (honour_read is 1 or honour_star is 1 or (select note from target_values) is not null))", + "INSERT INTO arsse_marks(subscription,article,read,starred,note) + select + (select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed), + id, + coalesce((select read from target_values) * honour_read,0), + coalesce((select starred from target_values),0), + coalesce((select note from target_values),'') + from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1 or coalesce((select note from target_values),'') <> '')" + ]; + $out = 0; + // wrap this UPDATE and INSERT together into a transaction + $tr = $this->begin(); + // if an edition context is specified, make sure it's valid if ($context->edition()) { - // if an edition is specified, filter for its previously identified article - $q->setWhere("arsse_articles.id is ?", "int", $edition['article']); + // make sure the edition exists + $edition = $this->articleValidateEdition($user, $context->edition); + // if the edition is not the latest, do not mark the read flag + if (!$edition['current']) { + $values[0] = null; + } } elseif ($context->article()) { - // if an article is specified, filter for it (it has already been validated above) - $q->setWhere("arsse_articles.id is ?", "int", $context->article); + // otherwise if an article context is specified, make sure it's valid + $this->articleValidateId($user, $context->article); } - if ($context->editions()) { - // if multiple specific editions have been requested, prepare a CTE to list them and their articles - if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->editions) > 50) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements - } - list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setCTE("requested_articles(id,edition)", - "SELECT article,id as edition from arsse_editions where edition in ($inParams)", - $inTypes, - $context->editions - ); - $q->setWhere("arsse_articles.id in (select id from requested_articles)"); - } elseif ($context->articles()) { - // if multiple specific articles have been requested, prepare a CTE to list them and their articles - if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > 50) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements - } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setCTE("requested_articles(id,edition)", - "SELECT id,(select max(id) from arsse_editions where article is arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams)", - $inTypes, - $context->articles - ); - $q->setWhere("arsse_articles.id in (select id from requested_articles)"); - } else { - // if neither list is specified, mock an empty table - $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0"); + // execute each query in sequence + foreach ($queries as $query) { + // first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles + $q = $this->articleQuery($user, $context, [ + "(not exists(select article from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert", + "((select read from target_values) is not null and (select read from target_values) is not (coalesce((select read from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article is arsse_articles.id) in (select edition from requested_articles))) as honour_read", + "((select starred from target_values) is not null and (select starred from target_values) is not (coalesce((select starred from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star", + ]); + // common table expression with the values to set + $q->setCTE("target_values(read,starred,note)", "SELECT ?,?,?", ["bool","bool","str"], $values); + // push the current query onto the CTE stack and execute the query we're actually interested in + $q->pushCTE("target_articles"); + $q->setBody($query); + $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } - // filter based on edition offset - if ($context->oldestEdition()) { - $q->setWhere("edition >= ?", "int", $context->oldestEdition); - } - if ($context->latestEdition()) { - $q->setWhere("edition <= ?", "int", $context->latestEdition); - } - // filter based on lastmod time - if ($context->modifiedSince()) { - $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); - } - if ($context->notModifiedSince()) { - $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); - } - // push the current query onto the CTE stack and execute the query we're actually interested in - $q->pushCTE("target_articles(id,feed,edition,modified_date,to_insert,honour_read,honour_star)"); - $q->setBody($query); - $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + // commit the transaction + $tr->commit(); + return $out; } - // commit the transaction - $tr->commit(); - return (bool) $out; } - public function articleStarredCount(string $user): int { + public function articleStarred(string $user): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - return $this->db->prepare("SELECT count(*) from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)", "str")->run($user)->getValue(); + return $this->db->prepare( + "SELECT + count(*) as total, + coalesce(sum(not read),0) as unread, + coalesce(sum(read),0) as read + FROM ( + select read from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?) + )", "str" + )->run($user)->getRow(); + } + + public function articleLabelsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $id = $this->articleValidateId($user, $id)['article']; + $out = $this->db->prepare("SELECT id,name from arsse_labels where owner is ? and exists(select id from arsse_label_members where article is ? and label is arsse_labels.id and assigned is 1)", "str", "int")->run($user, $id)->getAll(); + if (!$out) { + return $out; + } else { + // flatten the result to return just the label ID or name + return array_column($out, !$byName ? "id" : "name"); + } + } + + public function articleCategoriesGet(string $user, $id): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $id = $this->articleValidateId($user, $id)['article']; + $out = $this->db->prepare("SELECT name from arsse_categories where article is ? order by name", "int")->run($id)->getAll(); + if (!$out) { + return $out; + } else { + // flatten the result + return array_column($out, "name"); + } } public function articleCleanup(): bool { @@ -984,7 +1220,7 @@ class Database { protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( "SELECT @@ -1005,7 +1241,7 @@ class Database { protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { - throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( "SELECT @@ -1030,9 +1266,7 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - if (!$context) { - $context = new Context; - } + $context = $context ?? new Context; $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article is arsse_articles.id left join arsse_feeds on arsse_articles.feed is arsse_feeds.id"); if ($context->subscription()) { // if a subscription is specified, make sure it exists @@ -1045,4 +1279,194 @@ class Database { } return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + + public function labelAdd(string $user, array $data): int { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the label name + $name = array_key_exists("name", $data) ? $data['name'] : ""; + $this->labelValidateName($name, true); + // perform the insert + return $this->db->prepare("INSERT INTO arsse_labels(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); + } + + public function labelList(string $user, bool $includeEmpty = true): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT + id,name, + (select count(*) from arsse_label_members where label is id and assigned is 1) as articles, + (select count(*) from arsse_label_members + join arsse_marks on arsse_label_members.article is arsse_marks.article and arsse_label_members.subscription is arsse_marks.subscription + where label is id and assigned is 1 and read is 1 + ) as read + FROM arsse_labels where owner is ? and articles >= ? order by name + ", "str", "int" + )->run($user, !$includeEmpty); + } + + public function labelRemove(string $user, $id, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->labelValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $changes = $this->db->prepare("DELETE FROM arsse_labels where owner is ? and $field is ?", "str", $type)->run($user, $id)->changes(); + if (!$changes) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); + } + return true; + } + + public function labelPropertiesGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->labelValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $out = $this->db->prepare( + "SELECT + id,name, + (select count(*) from arsse_label_members where label is id and assigned is 1) as articles, + (select count(*) from arsse_label_members + join arsse_marks on arsse_label_members.article is arsse_marks.article and arsse_label_members.subscription is arsse_marks.subscription + where label is id and assigned is 1 and read is 1 + ) as read + FROM arsse_labels where $field is ? and owner is ? + ", $type, "str" + )->run($id, $user)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); + } + return $out; + } + + public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->labelValidateId($user, $id, $byName, false); + if (isset($data['name'])) { + $this->labelValidateName($data['name']); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $valid = [ + 'name' => "str", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_labels set $setClause, modified = CURRENT_TIMESTAMP where owner is ? and $field is ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); + } + return $out; + } + + public function labelArticlesGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // just do a syntactic check on the label ID + $this->labelValidateId($user, $id, $byName, false); + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label is id where assigned is 1 and $field is ? and owner is ?", $type, "str")->run($id, $user)->getAll(); + if (!$out) { + // if no results were returned, do a full validation on the label ID + $this->labelValidateId($user, $id, $byName, true, true); + // if the validation passes, return the empty result + return $out; + } else { + // flatten the result to return just the article IDs in a simple array + return array_column($out, "article"); + } + } + + public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the label ID, and get the numeric ID if matching by name + $id = $this->labelValidateId($user, $id, $byName, true)['id']; + $context = $context ?? new Context; + $out = 0; + // wrap this UPDATE and INSERT together into a transaction + $tr = $this->begin(); + // first update any existing entries with the removal or re-addition of their association + $q = $this->articleQuery($user, $context); + $q->setWhere("exists(select article from arsse_label_members where label is ? and article is arsse_articles.id)", "int", $id); + $q->pushCTE("target_articles"); + $q->setBody( + "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label is ? and assigned is not ? and article in (select id from target_articles)", + ["bool","int","bool"], + [!$remove, $id, !$remove] + ); + $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + // next, if we're not removing, add any new entries that need to be added + if (!$remove) { + $q = $this->articleQuery($user, $context); + $q->setWhere("not exists(select article from arsse_label_members where label is ? and article is arsse_articles.id)", "int", $id); + $q->pushCTE("target_articles"); + $q->setBody( + "INSERT INTO + arsse_label_members(label,article,subscription) + SELECT + ?,id, + (select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed) + FROM target_articles", + "int", $id + ); + $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + } + // commit the transaction + $tr->commit(); + return $out; + } + + protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { + if (!$byName && !ValueInfo::id($id)) { + // if we're not referring to a label by name and the ID is invalid, throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "int > 0"]); + } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) { + // otherwise if we are referring to a label by name but the ID is not a string, also throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "string"]); + } elseif ($checkDb) { + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $l = $this->db->prepare("SELECT id,name from arsse_labels where $field is ? and owner is ?", $type, "str")->run($id, $user)->getRow(); + if (!$l) { + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "label", 'id' => $id]); + } else { + return $l; + } + } + return [ + 'id' => !$byName ? $id : null, + 'name' => $byName ? $id : null, + ]; + } + + protected function labelValidateName($name): bool { + $info = ValueInfo::str($name); + if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { + throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); + } elseif ($info & ValueInfo::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); + } elseif (!($info & ValueInfo::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]); + } else { + return true; + } + } } diff --git a/lib/Db/AbstractResult.php b/lib/Db/AbstractResult.php index f0838d02..fe4df26d 100644 --- a/lib/Db/AbstractResult.php +++ b/lib/Db/AbstractResult.php @@ -13,17 +13,23 @@ abstract class AbstractResult implements Result { // actual public methods public function getValue() { - $this->next(); if ($this->valid()) { - $keys = array_keys($this->cur); - return $this->cur[array_shift($keys)]; + $out = array_shift($this->cur); + $this->next(); + return $out; + } else { + return null; } - return null; } public function getRow() { - $this->next(); - return ($this->valid() ? $this->cur : null); + if ($this->valid()) { + $out = $this->cur; + $this->next(); + return $out; + } else { + return null; + } } public function getAll(): array { diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index e8c4e7ce..1269a609 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -11,7 +11,6 @@ use JKingWeb\Arsse\Misc\Date; abstract class AbstractStatement implements Statement { protected $types = []; protected $isNullable = []; - protected $values = ['pre' => [], 'post' => []]; abstract public function runArray(array $values = []): Result; diff --git a/lib/Db/ResultAggregate.php b/lib/Db/ResultAggregate.php new file mode 100644 index 00000000..f934e176 --- /dev/null +++ b/lib/Db/ResultAggregate.php @@ -0,0 +1,50 @@ +data, function($sum, $value) {return $sum + $value->changes();}, 0); + } + + public function lastId() { + return $this->data[sizeof($this->data) - 1]->lastId(); + } + + // constructor/destructor + + public function __construct(Result ...$result) { + $this->data = $result; + } + + public function __destruct() { + $max = sizeof($this->data); + for ($a = 0; $a < $max; $a++) { + unset($this->data[$a]); + } + } + + // PHP iterator methods + + public function valid() { + while (!$this->cur && isset($this->data[$this->index])) { + $this->cur = $this->data[$this->index]->getRow(); + if (!$this->cur) { + $this->index++; + } + } + return (bool) $this->cur; + } +} diff --git a/lib/Db/ResultEmpty.php b/lib/Db/ResultEmpty.php new file mode 100644 index 00000000..f11d75ea --- /dev/null +++ b/lib/Db/ResultEmpty.php @@ -0,0 +1,25 @@ +act(__FUNCTION__, func_num_args(), $spec); } + public function folderShallow(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function latestEdition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -96,6 +117,16 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -117,4 +148,20 @@ class Context { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function label(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelName(string $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelled(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function annotated(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 32d7dfd4..9afc23de 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -14,6 +14,9 @@ class Query { protected $tCTE = []; // Common table expression type bindings protected $vCTE = []; // Common table expression binding values protected $jCTE = []; // Common Table Expression joins + protected $qJoin = []; // JOIN clause components + protected $tJoin = []; // JOIN clause type bindings + protected $vJoin = []; // JOIN clause binding values protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values @@ -47,6 +50,15 @@ class Query { return true; } + public function setJoin(string $join, $types = null, $values = null): bool { + $this->qJoin[] = $join; + if (!is_null($types)) { + $this->tJoin[] = $types; + $this->vJoin[] = $values; + } + return true; + } + public function setWhere(string $where, $types = null, $values = null): bool { $this->qWhere[] = $where; if (!is_null($types)) { @@ -81,6 +93,9 @@ class Query { $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qJoin = []; + $this->tJoin = []; + $this->vJoin = []; $this->order = []; $this->setLimit(0, 0); if (strlen($join)) { @@ -105,11 +120,19 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tWhere]; + return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vWhere]; + return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere]; + } + + public function getJoinTypes(): array { + return $this->tJoin; + } + + public function getJoinValues(): array { + return $this->vJoin; } public function getWhereTypes(): array { @@ -136,6 +159,10 @@ class Query { // add any joins against CTEs $out .= " ".implode(" ", $this->jCTE); } + // add any JOINs + if (sizeof($this->qJoin)) { + $out .= " ".implode(" ", $this->qJoin); + } // add any WHERE terms if (sizeof($this->qWhere)) { $out .= " WHERE ".implode(" AND ", $this->qWhere); diff --git a/lib/REST.php b/lib/REST.php index 3a867d12..c70e43be 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -20,15 +20,29 @@ class REST { 'strip' => '/index.php/apps/news/api/v1-2', 'class' => REST\NextCloudNews\V1_2::class, ], + 'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference + 'match' => '/tt-rss/api/', + 'strip' => '/tt-rss/api/', + 'class' => REST\TinyTinyRSS\API::class, + ], + 'ttrss_icon' => [ // Tiny Tiny RSS feed icons + 'match' => '/tt-rss/feed-icons/', + 'strip' => '/tt-rss/feed-icons/', + 'class' => REST\TinyTinyRSS\Icon::class, + ], // Other candidates: - // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md - // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 - // Feedbin v2 https://github.com/feedbin/feedbin-api - // Tiny Tiny RSS https://tt-rss.org/gitlab/fox/tt-rss/wikis/ApiReference + // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html // Fever https://feedafever.com/api - // NewsBlur http://www.newsblur.com/api + // Feedbin v2 https://github.com/feedbin/feedbin-api + // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown // CommaFeed https://www.commafeed.com/api/ + // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md + // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access + // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md + // Proprietary (centralized) entities: + // NewsBlur http://www.newsblur.com/api + // Feedly https://developer.feedly.com/ ]; public function __construct() { diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 41706443..f8ae0728 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Context; @@ -381,7 +382,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = $this->feedTranslate($sub); } $out = ['feeds' => $out]; - $out['starredCount'] = Arsse::$db->articleStarredCount(Arsse::$user->id); + $out['starredCount'] = Arsse::$db->articleStarred(Arsse::$user->id)['total']; $newest = Arsse::$db->editionLatest(Arsse::$user->id); if ($newest) { $out['newestItemId'] = $newest; @@ -508,11 +509,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // whether to return only updated items if ($data['lastModified']) { - $c->modifiedSince($data['lastModified']); + $c->markedSince($data['lastModified']); } // perform the fetch try { - $items = Arsse::$db->articleList(Arsse::$user->id, $c); + $items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid return new Response(422); @@ -575,19 +576,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { protected function articleMarkReadMulti(array $url, array $data): Response { // determine whether to mark read or unread $set = ($url[1]=="read"); - // start a transaction and loop through the items - $t = Arsse::$db->begin(); - $in = array_chunk($data['items'] ?? [], 50); - for ($a = 0; $a < sizeof($in); $a++) { - // initialize the matching context - $c = new Context; - $c->editions($in[$a]); - try { - Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); - } catch (ExceptionInput $e) { - } + // initialize the matching context + $c = new Context; + $c->editions($data['items'] ?? []); + try { + Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); + } catch (ExceptionInput $e) { } - $t->commit(); return new Response(204); } @@ -595,19 +590,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { protected function articleMarkStarredMulti(array $url, array $data): Response { // determine whether to mark starred or unstarred $set = ($url[1]=="star"); - // start a transaction and loop through the items - $t = Arsse::$db->begin(); - $in = array_chunk(array_column($data['items'] ?? [], "guidHash"), 50); - for ($a = 0; $a < sizeof($in); $a++) { - // initialize the matching context - $c = new Context; - $c->articles($in[$a]); - try { - Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); - } catch (ExceptionInput $e) { - } + // initialize the matching context + $c = new Context; + $c->articles(array_column($data['items'] ?? [], "guidHash")); + try { + Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); + } catch (ExceptionInput $e) { } - $t->commit(); return new Response(204); } diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php new file mode 100644 index 00000000..4dd60dae --- /dev/null +++ b/lib/REST/TinyTinyRSS/API.php @@ -0,0 +1,1469 @@ + ValueInfo::T_STRING, // the function ("operation") to perform + 'sid' => ValueInfo::T_STRING, // session ID + 'seq' => ValueInfo::T_INT, // request number from client + 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login` + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed` + 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories` + 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds` + 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories + 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines` + 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // name for categories, feed, and labels + 'parent_id' => ValueInfo::T_INT, // parent category for `addCategory` and `moveCategory` + 'category_id' => ValueInfo::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions + 'cat_id' => ValueInfo::T_INT, // parent category for `getFeeds` + 'label_id' => ValueInfo::T_INT, // label ID in label-related functions + 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // URL of feed in `subscribeToFeed` + 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // remote user name in `subscribeToFeed` + 'feed_id' => ValueInfo::T_INT, // feed, label, or category ID for various functions + 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether 'feed_id' refers to a category + 'article_id' => ValueInfo::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle` + 'article_ids' => ValueInfo::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel` + 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel` + 'limit' => ValueInfo::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines` + 'offset' => ValueInfo::T_INT, // number of records to skip in `getFeeds`, for pagination + 'skip' => ValueInfo::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination + 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article excerpts in `getHeadlines` + 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article content in `getHeadlines` + 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article enclosures in `getHeadlines` + 'view_mode' => ValueInfo::T_STRING, // various filters for `getHeadlines` + 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified + 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines` + 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines` + 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented) + 'field' => ValueInfo::T_INT, // which state to change in `updateArticle` + 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle` + 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note + ]; + // generic error construct + const FATAL_ERR = [ + 'seq' => null, + 'status' => 1, + 'content' => ['error' => "MALFORMED_INPUT"], + ]; + + public function __construct() { + } + + public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + if ($req->method=="OPTIONS") { + // respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method + return new Response(204, "", "", [ + "Allow: POST", + "Accept: application/json, text/json", + ]); + } + if ($req->body) { + // only JSON entities are allowed, but Content-Type is ignored, as is request method + $data = @json_decode($req->body, true); + if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { + return new Response(200, self::FATAL_ERR); + } + try { + // normalize input + try { + $data['seq'] = isset($data['seq']) ? $data['seq'] : 0; + $data = $this->normalizeInput($data, self::VALID_INPUT, "unix"); + } catch (ExceptionType $e) { + throw new Exception("INCORRECT_USAGE"); + } + if (strtolower((string) $data['op']) != "login") { + // unless logging in, a session identifier is required + $this->resumeSession((string) $data['sid']); + } + $method = "op".ucfirst($data['op']); + if (!method_exists($this, $method)) { + // TT-RSS operations are case-insensitive by dint of PHP method names being case-insensitive; this will only trigger if the method really doesn't exist + throw new Exception("UNKNOWN_METHOD", ['method' => $data['op']]); + } + return new Response(200, [ + 'seq' => $data['seq'], + 'status' => 0, + 'content' => $this->$method($data), + ]); + } catch (Exception $e) { + return new Response(200, [ + 'seq' => $data['seq'], + 'status' => 1, + 'content' => $e->getData(), + ]); + } catch (AbstractException $e) { + return new Response(500); + } + } else { + // absence of a request body indicates an error + return new Response(200, self::FATAL_ERR); + } + } + + protected function resumeSession(string $id): bool { + try { + // verify the supplied session is valid + $s = Arsse::$db->sessionResume($id); + } catch (\JKingWeb\Arsse\User\ExceptionSession $e) { + // if not throw an exception + throw new Exception("NOT_LOGGED_IN"); + } + // resume the session (currently only the user name) + Arsse::$user->id = $s['user']; + return true; + } + + public function opGetApiLevel(array $data): array { + return ['level' => self::LEVEL]; + } + + public function opGetVersion(array $data): array { + return [ + 'version' => self::VERSION, + 'arsse_version' => Arsse::VERSION, + ]; + } + + public function opLogin(array $data): array { + if (Arsse::$user->auth((string) $data['user'], (string) $data['password'])) { + $id = Arsse::$db->sessionCreate($data['user']); + return [ + 'session_id' => $id, + 'api_level' => self::LEVEL + ]; + } else { + throw new Exception("LOGIN_ERROR"); + } + } + + public function opLogout(array $data): array { + Arsse::$db->sessionDestroy(Arsse::$user->id, $data['sid']); + return ['status' => "OK"]; + } + + public function opIsLoggedIn(array $data): array { + // session validity is already checked by the dispatcher, so we need only return true + return ['status' => true]; + } + + public function opGetConfig(array $data): array { + return [ + 'icons_dir' => "feed-icons", + 'icons_url' => "feed-icons", + 'daemon_is_running' => Service::hasCheckedIn(), + 'num_feeds' => Arsse::$db->subscriptionCount(Arsse::$user->id), + ]; + } + + public function opGetUnread(array $data): array { + // simply sum the unread count of each subscription + $out = 0; + foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { + $out += $sub['unread']; + } + return ['unread' => (string) $out]; // string cast to be consistent with TTRSS + } + + public function opGetCounters(array $data): array { + $user = Arsse::$user->id; + $starred = Arsse::$db->articleStarred($user); + $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); + $countAll = 0; + $countSubs = 0; + $feeds = []; + $labels = []; + // do a first pass on categories: add the ID to a lookup table and set the unread counter to zero + $categories = Arsse::$db->folderList($user)->getAll(); + $catmap = []; + for ($a = 0; $a < sizeof($categories); $a++) { + $catmap[(int) $categories[$a]['id']] = $a; + $categories[$a]['counter'] = 0; + } + // add the "Uncategorized" and "Labels" virtual categories to the list + $catmap[self::CAT_UNCATEGORIZED] = sizeof($categories); + $categories[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'parent' => 0, 'children' => 0, 'counter' => 0]; + $catmap[self::CAT_LABELS] = sizeof($categories); + $categories[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'parent' => 0, 'children' => 0, 'counter' => 0]; + // prepare data for each subscription; we also add unread counts for their host categories + foreach (Arsse::$db->subscriptionList($user) as $f) { + if ($f['unread']) { + // add the feed to the list of feeds + $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS + // add the feed's unread count to the global unread count + $countAll += $f['unread']; + // add the feed's unread count to its category unread count + $categories[$catmap[(int) $f['folder']]]['counter'] += $f['unread']; + } + // increment the global feed count + $countSubs += 1; + } + // prepare data for each non-empty label + foreach (Arsse::$db->labelList($user, false) as $l) { + $unread = $l['articles'] - $l['read']; + $labels[] = ['id' => $this->labelOut($l['id']), 'counter' => $unread, 'auxcounter' => $l['articles']]; + $categories[$catmap[self::CAT_LABELS]]['counter'] += $unread; + } + // do a second pass on categories, summing descendant unread counts for ancestors + $cats = $categories; + $catCounts = []; + while ($cats) { + foreach ($cats as $c) { + if ($c['children']) { + // only act on leaf nodes + continue; + } + if ($c['parent']) { + // if the category has a parent, add its counter to the parent's counter, and decrement the parent's child count + $cats[$catmap[$c['parent']]]['counter'] += $c['counter']; + $cats[$catmap[$c['parent']]]['children'] -= 1; + } + $catCounts[$c['id']] = $c['counter']; + // remove the category from the input list + unset($cats[$catmap[$c['id']]]); + } + } + // do a third pass on categories, building a final category list + foreach ($categories as $c) { + // only include categories with unread articles + if ($catCounts[$c['id']]) { + $cats[] = ['id' => $c['id'], 'kind' => "cat", 'counter' => $catCounts[$c['id']]]; + } + } + // prepare data for the virtual feeds and other counters + $special = [ + ['id' => "global-unread", 'counter' => $countAll], //this should not count archived articles, but we do not have an archive + ['id' => "subscribed-feeds", 'counter' => $countSubs], + ['id' => self::FEED_ARCHIVED, 'counter' => 0, 'auxcounter' => 0], // Archived articles + ['id' => self::FEED_STARRED, 'counter' => $starred['unread'], 'auxcounter' => $starred['total']], // Starred articles + ['id' => self::FEED_PUBLISHED, 'counter' => 0, 'auxcounter' => 0], // Published articles + ['id' => self::FEED_FRESH, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles + ['id' => self::FEED_ALL, 'counter' => $countAll, 'auxcounter' => 0], // All articles + ]; + return array_merge($special, $labels, $feeds, $cats); + } + + public function opGetFeedTree(array $data) : array { + $all = $data['include_empty'] ?? false; + $user = Arsse::$user->id; + $tSpecial = [ + 'type' => "feed", + 'auxcounter' => 0, + 'error' => "", + 'updated' => "", + ]; + $out = []; + // get the lists of categories and feeds + $cats = Arsse::$db->folderList($user, null, true)->getAll(); + $subs = Arsse::$db->subscriptionList($user)->getAll(); + // start with the special feeds + $out[] = [ + 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), + 'id' => "CAT:".self::CAT_SPECIAL, + 'bare_id' => self::CAT_SPECIAL, + 'type' => "category", + 'unread' => 0, + 'items' => [ + array_merge([ // All articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.All"), + 'id' => "FEED:".self::FEED_ALL, + 'bare_id' => self::FEED_ALL, + 'icon' => "images/folder.png", + 'unread' => array_reduce($subs, function($sum, $value) {return $sum + $value['unread'];}, 0), // the sum of all feeds' unread is the total unread + ], $tSpecial), + array_merge([ // Fresh articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"), + 'id' => "FEED:".self::FEED_FRESH, + 'bare_id' => self::FEED_FRESH, + 'icon' => "images/fresh.png", + 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))), + ], $tSpecial), + array_merge([ // Starred articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"), + 'id' => "FEED:".self::FEED_STARRED, + 'bare_id' => self::FEED_STARRED, + 'icon' => "images/star.png", + 'unread' => Arsse::$db->articleStarred($user)['unread'], + ], $tSpecial), + array_merge([ // Published articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Published"), + 'id' => "FEED:".self::FEED_PUBLISHED, + 'bare_id' => self::FEED_PUBLISHED, + 'icon' => "images/feed.png", + 'unread' => 0, // TODO: unread count should be populated if the Published feed is ever implemented + ], $tSpecial), + array_merge([ // Archived articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"), + 'id' => "FEED:".self::FEED_ARCHIVED, + 'bare_id' => self::FEED_ARCHIVED, + 'icon' => "images/archive.png", + 'unread' => 0, // Article archiving is not exposed by the API, so this is always zero + ], $tSpecial), + array_merge([ // Recently read + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Read"), + 'id' => "FEED:".self::FEED_READ, + 'bare_id' => self::FEED_READ, + 'icon' => "images/time.png", + 'unread' => 0, // this is by definition zero; unread articles do not appear in this feed + ], $tSpecial), + ], + ]; + // next prepare labels + $items = []; + $unread = 0; + // add each label to a holding list (NOTE: the 'include_empty' parameter does not affect whether labels with zero total articles are shown: all labels are always shown) + foreach (Arsse::$db->labelList($user, true) as $l) { + $items[] = [ + 'name' => $l['name'], + 'id' => "FEED:".$this->labelOut($l['id']), + 'bare_id' => $this->labelOut($l['id']), + 'unread' => 0, + 'icon' => "images/label.png", + 'type' => "feed", + 'auxcounter' => 0, + 'error' => "", + 'updated' => "", + 'fg_color' => "", + 'bg_color' => "", + ]; + $unread += ($l['articles'] - $l['read']); + } + // if there are labels, all the label category, + if ($items) { + $out[] = [ + 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), + 'id' => "CAT:".self::CAT_LABELS, + 'bare_id' => self::CAT_LABELS, + 'type' => "category", + 'unread' => $unread, + 'items' => $items, + ]; + } + // get the lists of categories and feeds + $cats = Arsse::$db->folderList($user, null, true)->getAll(); + $subs = Arsse::$db->subscriptionList($user)->getAll(); + // process all the top-level categories; their contents are gathered recursively in another function + $items = $this->enumerateCategories($cats, $subs, null, $all); + $out = array_merge($out, $items['list']); + // process uncategorized feeds; exclude the "Uncategorized" category if there are no orphan feeds and we're not displaying empties + $items = $this->enumerateFeeds($subs, null); + if ($items || !$all) { + $out[] = [ + 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), + 'id' => "CAT:".self::CAT_UNCATEGORIZED, + 'bare_id' => self::CAT_UNCATEGORIZED, + 'type' => "category", + 'auxcounter' => 0, + 'unread' => 0, + 'child_unread' => 0, + 'checkbox' => false, + 'parent_id' => null, + 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", sizeof($items)), + 'items' => $items, + ]; + } + // return the result wrapped in some boilerplate + return ['categories' => ['identifier' => "id", 'label' => "name", 'items' => $out]]; + } + + protected function enumerateFeeds(array $subs, int $parent = null): array { + $out = []; + foreach ($subs as $s) { + if ($s['folder'] != $parent) { + continue; + } + $out[] = [ + 'name' => $s['title'], + 'id' => "FEED:".$s['id'], + 'bare_id' => $s['id'], + 'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false, + 'error' => (string) $s['err_msg'], + 'param' => Date::transform($s['updated'], "iso8601", "sql"), + 'unread' => 0, + 'auxcounter' => 0, + 'checkbox' => false, + // NOTE: feeds don't have a type property (even though both labels and special feeds do); don't ask me why + ]; + } + return $out; + } + + protected function enumerateCategories(array $cats, array $subs, int $parent = null, bool $all = false): array { + $out = []; + $feedTotal = 0; + foreach ($cats as $c) { + if ($c['parent'] != $parent || (!$all && !($c['children'] + $c['feeds']))) { + // if the category is the wrong level, or if it's empty and we're not including empties, skip it + continue; + } + $children = $c['children'] ? $this->enumerateCategories($cats, $subs, $c['id'], $all) : ['list' => [], 'feeds' => 0]; + $feeds = $c['feeds'] ? $this->enumerateFeeds($subs, $c['id']) : []; + $count = sizeof($feeds) + $children['feeds']; + $out[] = [ + 'name' => $c['name'], + 'id' => "CAT:".$c['id'], + 'bare_id' => $c['id'], + 'parent_id' => $c['parent'], // top-level categories are not supposed to have this property; we deviated and have the property set to null because it's simpler that way + 'type' => "category", + 'auxcounter' => 0, + 'unread' => 0, + 'child_unread' => 0, + 'checkbox' => false, + 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", $count), + 'items' => array_merge($children['list'], $feeds), + ]; + $feedTotal += $count; + } + return ['list' => $out, 'feeds' => $feedTotal]; + } + + public function opGetCategories(array $data): array { + // normalize input + $all = $data['include_empty'] ?? false; + $read = !($data['unread_only'] ?? false); + $deep = !($data['enable_nested'] ?? false); + $user = Arsse::$user->id; + // for each category, add the ID to a lookup table, set the number of unread to zero, and assign an increasing order index + $cats = Arsse::$db->folderList($user, null, $deep)->getAll(); + $map = []; + for ($a = 0; $a < sizeof($cats); $a++) { + $cats[$a]['id'] = (string) $cats[$a]['id']; // real categories have IDs as strings in TTRSS + $map[$cats[$a]['id']] = $a; + $cats[$a]['unread'] = 0; + $cats[$a]['order'] = $a + 1; + } + // add the "Uncategorized", "Special", and "Labels" virtual categories to the list + $map[self::CAT_UNCATEGORIZED] = sizeof($cats); + $cats[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'children' => 0, 'unread' => 0, 'feeds' => 0]; + $map[self::CAT_SPECIAL] = sizeof($cats); + $cats[] = ['id' => self::CAT_SPECIAL, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), 'children' => 0, 'unread' => 0, 'feeds' => 6]; + $map[self::CAT_LABELS] = sizeof($cats); + $cats[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'children' => 0, 'unread' => 0, 'feeds' => 0]; + // for each subscription, add the unread count to its category, and increment the category's feed count + $subs = Arsse::$db->subscriptionList($user); + foreach ($subs as $sub) { + // note we use top_folder if we're in "nested" mode + $f = $map[(int) ($deep ? $sub['folder'] : $sub['top_folder'])]; + $cats[$f]['unread'] += $sub['unread']; + if (!$cats[$f]['id']) { + $cats[$f]['feeds'] += 1; + } + } + // for each label, add the unread count to the labels category, and increment the labels category's feed count + $labels = Arsse::$db->labelList($user); + $f = $map[self::CAT_LABELS]; + foreach ($labels as $label) { + $cats[$f]['unread'] += $label['articles'] - $label['read']; + $cats[$f]['feeds'] += 1; + } + // get the unread counts for the special feeds + // FIXME: this is pretty inefficient + $f = $map[self::CAT_SPECIAL]; + $cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred + $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); // fresh + if (!$read) { + // if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties) + $count = sizeof($cats); + for ($a = 0; $a < $count; $a++) { + if (!$cats[$a]['unread']) { + unset($cats[$a]); + } + } + $cats = array_values($cats); + } elseif (!$all) { + // otherwise if we're not including empty entries, remove categories with no children and no feeds + $count = sizeof($cats); + for ($a = 0; $a < $count; $a++) { + if (($cats[$a]['children'] + $cats[$a]['feeds']) < 1) { + unset($cats[$a]); + } + } + $cats = array_values($cats); + } + // transform the result and return + $out = []; + for ($a = 0; $a < sizeof($cats); $a++) { + if ($cats[$a]['id']==-2) { + // the Labels category has its unread count as a string in TTRSS (don't ask me why) + settype($cats[$a]['unread'], "string"); + } + $out[] = $this->fieldMapNames($cats[$a], [ + 'id' => "id", + 'title' => "name", + 'unread' => "unread", + 'order_id' => "order", + ]); + } + return $out; + } + + public function opAddCategory(array $data) { + $in = [ + 'name' => $data['caption'], + 'parent' => $data['parent_id'], + ]; + try { + return (string) Arsse::$db->folderAdd(Arsse::$user->id, $in); // output is a string in TTRSS + } catch (ExceptionInput $e) { + switch ($e->getCode()) { + case 10236: // folder already exists + // retrieve the ID of the existing folder; duplicating a folder silently returns the existing one + $folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false); + foreach ($folders as $folder) { + if ($folder['name']==$in['name']) { + return (string) ((int) $folder['id']); // output is a string in TTRSS + } + } + return false; // @codeCoverageIgnore + case 10235: // parent folder does not exist; this returns false as an ID + return false; + default: // other errors related to input + throw new Exception("INCORRECT_USAGE"); + } + } + } + + public function opRemoveCategory(array $data) { + if (!ValueInfo::id($data['category_id'])) { + // if the folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + try { + // attempt to remove the folder + Arsse::$db->folderRemove(Arsse::$user->id, (int) $data['category_id']); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opMoveCategory(array $data) { + if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) { + // if the folder or parent is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'parent' => (int) $data['parent_id'], + ]; + try { + // try to move the folder + Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameCategory(array $data) { + $info = ValueInfo::str($data['caption']); + if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the folder or its new name are invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => $data['caption'], + ]; + try { + // try to rename the folder + Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opGetFeeds(array $data): array { + $user = Arsse::$user->id; + // normalize input + $cat = $data['cat_id'] ?? 0; + $unread = $data['unread_only'] ?? false; + $limit = $data['limit'] ?? 0; + $offset = $data['offset'] ?? 0; + $nested = $data['include_nested'] ?? false; + // if a special category was selected, nesting does not apply + if (!ValueInfo::id($cat)) { + $nested = false; + // if the All, Special, or Labels category was selected, pagination also does not apply + if (in_array($cat, [self::CAT_ALL, self::CAT_SPECIAL, self::CAT_LABELS])) { + $limit = 0; + $offset = 0; + } + } + // retrieve or build the list of relevant feeds + $out = []; + $subs = []; + $count = 0; + // if the category is the special Labels category or the special All category (which includes labels), add labels to the list + if ($cat==self::CAT_ALL || $cat==self::CAT_LABELS) { + // NOTE: unused labels are not included + foreach (Arsse::$db->labelList($user, false) as $l) { + if ($unread && !$l['unread']) { + continue; + } + $out[] = [ + 'id' => $this->labelOut($l['id']), + 'title' => $l['name'], + 'unread' => (string) $l['unread'], // the unread count of labels is output as a string in TTRSS + 'cat_id' => self::CAT_LABELS, + ]; + } + } + // if the category is the special Special (!) category or the special All category (which includes "special" feeds), add those feeds to the list + if ($cat==self::CAT_ALL || $cat==self::CAT_SPECIAL) { + // gather some statistics + $starred = Arsse::$db->articleStarred($user)['unread']; + $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); + $global = Arsse::$db->articleCount($user, (new Context)->unread(true)); + $published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly + $archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself + // build the list; exclude anything with zero unread if requested + if (!$unread || $starred) { + $out[] = [ + 'id' => self::FEED_STARRED, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"), + 'unread' => (string) $starred, // output is a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $published) { + $out[] = [ + 'id' => self::FEED_PUBLISHED, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Published"), + 'unread' => (string) $published, // output is a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $fresh) { + $out[] = [ + 'id' => self::FEED_FRESH, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"), + 'unread' => (string) $fresh, // output is a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $global) { + $out[] = [ + 'id' => self::FEED_ALL, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.All"), + 'unread' => (string) $global, // output is a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread) { + $out[] = [ + 'id' => self::FEED_READ, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Read"), + 'unread' => 0, // zero by definition; this one is -NOT- a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $archived) { + $out[] = [ + 'id' => self::FEED_ARCHIVED, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"), + 'unread' => (string) $archived, // output is a string in TTRSS + 'cat_id' => self::CAT_SPECIAL, + ]; + } + } + // categories and real feeds have a sequential order index; we don't store this, so we just increment with each entry from here + $order = 0; + // if a "nested" list was requested, append the category's child categories to the putput + if ($nested) { + try { + // NOTE: the list is a flat one: it includes children, but not other descendents + foreach (Arsse::$db->folderList($user, $cat, false) as $c) { + // get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode + $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder($c['id'])); + if (!$unread || $count) { + $out[] = [ + 'id' => $c['id'], + 'title' => $c['name'], + 'unread' => $count, + 'is_cat' => true, + 'order_id' => ++$order, + ]; + } + } + } catch (ExceptionInput $e) { + // in case of errors (because the category does not exist) return the list so far (which should be empty) + return $out; + } + } + try { + if ($cat==self::CAT_NOT_SPECIAL || $cat==self::CAT_ALL) { + // if the "All" or "Not Special" categories were selected this returns all subscription, to any depth + $subs = Arsse::$db->subscriptionList($user, null, true); + } elseif ($cat==self::CAT_UNCATEGORIZED) { + // the "Uncategorized" special category returns subscriptions in the root, without going deeper + $subs = Arsse::$db->subscriptionList($user, null, false); + } else { + // other categories return their subscriptions, without going deeper + $subs = Arsse::$db->subscriptionList($user, $cat, false); + } + } catch (ExceptionInput $e) { + // in case of errors (invalid category), return what we have so far + return $out; + } + // append subscriptions to the output + $order = 0; + $count = 0; + foreach ($subs as $s) { + $order++; + if ($unread && !$s['unread']) { + // ignore any subscriptions with zero unread in "unread-only" mode + continue; + } elseif ($offset > 0) { + // skip as many subscriptions as necessary to remove any requested offset + $offset--; + continue; + } elseif ($limit && $count >= $limit) { + // if we've reached the requested limit, stop + // NOTE: TT-RSS blindly accepts negative limits and returns an empty array + break; + } + // otherwise, append the subscription + $out[] = [ + 'id' => $s['id'], + 'title' => $s['title'], + 'unread' => $s['unread'], + 'cat_id' => (int) $s['folder'], + 'feed_url' => $s['url'], + 'has_icon' => (bool) $s['favicon'], + 'last_updated' => (int) Date::transform($s['updated'], "unix", "sql"), + 'order_id' => $order, + ]; + $count++; + } + return $out; + } + + protected function feedError(FeedException $e): array { + // N.B.: we don't return code 4 (multiple feeds discovered); we simply pick the first feed discovered + switch ($e->getCode()) { + case 10502: // invalid URL + return ['code' => 2, 'message' => $e->getMessage()]; + case 10521: // no feeds discovered + return ['code' => 3, 'message' => $e->getMessage()]; + case 10511: + case 10512: + case 10522: // malformed data + return ['code' => 6, 'message' => $e->getMessage()]; + default: // unable to download + return ['code' => 5, 'message' => $e->getMessage()]; + } + } + + public function opSubscribeToFeed(array $data): array { + if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) { + // if the feed URL or the category ID is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $url = (string) $data['feed_url']; + $folder = (int) $data['category_id']; + $fetchUser = (string) $data['login']; + $fetchPassword = (string) $data['password']; + // check to make sure the requested folder exists before doing anything else, if one is specified + if ($folder) { + try { + Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder); + } catch (ExceptionInput $e) { + // folder does not exist: TT-RSS is a bit weird in this case and returns a feed ID of 0. It checks the feed first, but we do not + return ['code' => 1, 'feed_id' => 0]; + } + } + try { + $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $url, $fetchUser, $fetchPassword); + } catch (ExceptionInput $e) { + // subscription already exists; retrieve the existing ID and return that with the correct code + for ($triedDiscovery = 0; $triedDiscovery <= 1; $triedDiscovery++) { + $subs = Arsse::$db->subscriptionList(Arsse::$user->id); + $id = false; + foreach ($subs as $sub) { + if ($sub['url']===$url) { + $id = (int) $sub['id']; + break; + } + } + if ($id) { + break; + } elseif (!$triedDiscovery) { + // if we didn't find the ID we perform feed discovery for the next iteration; this is pretty messy: discovery ends up being done twice because it was already done in $db->subscriptionAdd() + try { + $url = Feed::discover($url, $fetchUser, $fetchPassword); + } catch (FeedException $e) { + // feed errors (handled above) + return $this->feedError($e); + } + } + } + return ['code' => 0, 'feed_id' => $id]; + } catch (FeedException $e) { + // feed errors (handled above) + return $this->feedError($e); + } + // if all went well, move the new subscription to the requested folder (if one was requested) + try { + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]); + } catch (ExceptionInput $e) { + // ignore errors + } + return ['code' => 1, 'feed_id' => $id]; + } + + public function opUnsubscribeFeed(array $data): array { + try { + // attempt to remove the feed + Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']); + } catch (ExceptionInput $e) { + throw new Exception("FEED_NOT_FOUND"); + } + return ['status' => "OK"]; + } + + public function opMoveFeed(array $data) { + if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) { + // if the feed or folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'folder' => $data['category_id'], + ]; + try { + // try to move the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameFeed(array $data) { + $info = ValueInfo::str($data['caption']); + if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the feed ID or name is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => $data['caption'], + ]; + try { + // try to rename the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opUpdateFeed(array $data): array { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { + // if the feed is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + try { + Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']); + } catch (ExceptionInput $e) { + throw new Exception("FEED_NOT_FOUND"); + } + return ['status' => "OK"]; + } + + protected function labelIn($id, bool $throw = true): int { + if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) { + if ($throw) { + throw new Exception("INCORRECT_USAGE"); + } else { + return 0; + } + } + return (abs($id) - self::LABEL_OFFSET); + } + + protected function labelOut(int $id): int { + return ($id * -1 - self::LABEL_OFFSET); + } + + public function opGetLabels(array $data): array { + // this function doesn't complain about invalid article IDs + $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0; + try { + $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : []; + } catch (ExceptionInput $e) { + $list = []; + } + $out = []; + foreach (Arsse::$db->labelList(Arsse::$user->id) as $l) { + $out[] = [ + 'id' => $this->labelOut($l['id']), + 'caption' => $l['name'], + 'fg_color' => "", + 'bg_color' => "", + 'checked' => in_array($l['id'], $list), + ]; + } + return $out; + } + + public function opAddLabel(array $data) { + $in = [ + 'name' => (string) $data['caption'], + ]; + try { + return $this->labelOut(Arsse::$db->labelAdd(Arsse::$user->id, $in)); + } catch (ExceptionInput $e) { + switch ($e->getCode()) { + case 10236: // label already exists + // retrieve the ID of the existing label; duplicating a label silently returns the existing one + return $this->labelOut(Arsse::$db->labelPropertiesGet(Arsse::$user->id, $in['name'], true)['id']); + default: // other errors related to input + throw new Exception("INCORRECT_USAGE"); + } + } + } + + public function opRemoveLabel(array $data) { + // normalize the label ID; missing or invalid IDs are rejected + $id = $this->labelIn($data['label_id']); + try { + // attempt to remove the label + Arsse::$db->labelRemove(Arsse::$user->id, $id); + } catch (ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameLabel(array $data) { + // normalize input; missing or invalid IDs are rejected + $id = $this->labelIn($data['label_id']); + $name = (string) $data['caption']; + try { + // try to rename the folder + Arsse::$db->labelPropertiesSet(Arsse::$user->id, $id, ['name' => $name]); + } catch (ExceptionInput $e) { + if ($e->getCode()==10237) { + // if the supplied ID was invalid, report an error; other errors are to be ignored + throw new Exception("INCORRECT_USAGE"); + } + } + return null; + } + + public function opSetArticleLabel(array $data): array { + $label = $this->labelIn($data['label_id']); + $articles = explode(",", (string) $data['article_ids']); + $assign = $data['assign'] ?? false; + $out = 0; + $in = array_chunk($articles, 50); + for ($a = 0; $a < sizeof($in); $a++) { + // initialize the matching context + $c = new Context; + $c->articles($in[$a]); + try { + $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign); + } catch (ExceptionInput $e) { + } + } + return ['status' => "OK", 'updated' => $out]; + } + + public function opCatchUpFeed(array $data): array { + $id = $data['feed_id'] ?? self::FEED_ARCHIVED; + $cat = $data['is_cat'] ?? false; + $out = ['status' => "OK"]; + // first prepare the context; unsupported contexts simply return early + $c = new Context; + if ($cat) { // categories + switch ($id) { + case self::CAT_SPECIAL: + case self::CAT_NOT_SPECIAL: + case self::CAT_ALL: + // not valid + return $out; + case self::CAT_UNCATEGORIZED: + // this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root + $c->folderShallow(0); + break; + case self::CAT_LABELS: + $c->labelled(true); + break; + default: + // any actual category + $c->folder($id); + break; + } + } else { // feeds + if ($this->labelIn($id, false)) { // labels + $c->label($this->labelIn($id)); + } else { + switch ($id) { + case self::FEED_ARCHIVED: + // not implemented (also, evidently, not implemented in TTRSS) + return $out; + case self::FEED_STARRED: + $c->starred(true); + break; + case self::FEED_PUBLISHED: + // not implemented + // TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly + return $out; + case self::FEED_FRESH: + $c->modifiedSince(Date::sub("PT24H")); + break; + case self::FEED_ALL: + // no context needed here + break; + case self::FEED_READ: + // everything in the Recently read feed is, by definition, already read + return $out; + default: + // any actual feed + $c->subscription($id); + } + } + } + // perform the marking + try { + Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); + } catch (ExceptionInput $e) { + // ignore all errors + } + // return boilerplate output + return $out; + } + + public function opUpdateArticle(array $data): array { + // normalize input + $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_ids']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]); + if (!$articles) { + // if there are no valid articles this is an error + throw new Exception("INCORRECT_USAGE"); + } + $out = 0; + $tr = Arsse::$db->begin(); + switch ($data['field']) { + case 0: // starred + switch ($data['mode']) { + case 0: // set false + case 1: // set true + $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => (bool) $data['mode']], (new Context)->articles($articles)); + break; + case 2: //toggle + $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], (new Context)->articles($articles)->starred(false)); + $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], (new Context)->articles($articles)->starred(true)); + break; + default: + throw new Exception("INCORRECT_USAGE"); + } + break; + case 1: // published + switch ($data['mode']) { + case 0: // set false + case 1: // set true + case 2: //toggle + // TODO: the Published feed is not yet implemeted; once it is the updateArticle operation must be amended accordingly + break; + default: + throw new Exception("INCORRECT_USAGE"); + } + break; + case 2: // unread + // NOTE: we use a "read" flag rather than "unread", so the booleans are swapped + switch ($data['mode']) { + case 0: // set false + case 1: // set true + $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => !$data['mode']], (new Context)->articles($articles)); + break; + case 2: //toggle + $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->articles($articles)->unread(true)); + $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], (new Context)->articles($articles)->unread(false)); + break; + default: + throw new Exception("INCORRECT_USAGE"); + } + break; + case 3: // article note + $out += Arsse::$db->articleMark(Arsse::$user->id, ['note' => (string) $data['data']], (new Context)->articles($articles)); + break; + default: + throw new Exception("INCORRECT_USAGE"); + } + $tr->commit(); + return ['status' => "OK", 'updated' => $out]; + } + + public function opGetArticle(array $data): array { + // normalize input + $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_id']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]); + if (!$articles) { + // if there are no valid articles this is an error + throw new Exception("INCORRECT_USAGE"); + } + $tr = Arsse::$db->begin(); + // retrieve the list of label names for the user + $labels = []; + foreach (Arsse::$db->labelList(Arsse::$user->id, false) as $label) { + $labels[$label['id']] = $label['name']; + } + // retrieve the requested articles + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)) as $article) { + $out[] = [ + 'id' => (string) $article['id'], // string cast to be consistent with TTRSS + 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null, + 'title' => $article['title'], + 'link' => $article['url'], + 'labels' => $this->articleLabelList($labels, $article['id']), + 'unread' => (bool) $article['unread'], + 'marked' => (bool) $article['starred'], + 'published' => false, // TODO: if the Published feed is implemented, the getArticle operation should be amended accordingly + 'comments' => "", // FIXME: What is this? + 'author' => $article['author'], + 'updated' => Date::transform($article['edited_date'], "unix", "sql"), + 'feed_id' => (string) $article['subscription'], // string cast to be consistent with TTRSS + 'feed_title' => $article['subscription_title'], + 'attachments' => $article['media_url'] ? [[ + 'id' => (string) 0, // string cast to be consistent with TTRSS; nonsense ID because we don't use them for enclosures + 'content_url' => $article['media_url'], + 'content_type' => $article['media_type'], + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => (string) $article['id'], // string cast to be consistent with TTRSS + ]] : [], // TODO: We need to support multiple enclosures + 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API + 'note' => strlen((string) $article['note']) ? $article['note'] : null, + 'lang' => "", // FIXME: picoFeed should be able to retrieve this information + 'content' => $article['content'], + ]; + } + return $out; + } + + protected function articleLabelList(array $labels, int $id): array { + $out = []; + if (!$labels) { + return $out; + } + foreach (Arsse::$db->articleLabelsGet(Arsse::$user->id, $id) as $label) { + $out[] = [ + $this->labelOut($label), // ID + $labels[$label], // name + "", // foreground colour + "", // background colour + ]; + } + return $out; + } + + public function opGetCompactHeadlines(array $data): array { + // getCompactHeadlines supports fewer features than getHeadlines + $data = [ + 'feed_id' => $data['feed_id'], + 'view_mode' => $data['view_mode'], + 'since_id' => $data['since_id'], + 'limit' => $data['limit'], + 'skip' => $data['skip'], + ]; + $data = $this->normalizeInput($data, self::VALID_INPUT, "unix"); + // fetch the list of IDs + $out = []; + try { + foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { + $out[] = ['id' => $row['id']]; + } + } catch (ExceptionInput $e) { + // ignore database errors (feeds/categories that don't exist) + } + return $out; + } + + public function opGetHeadlines(array $data): array { + // normalize input + $data['limit'] = max(min(!$data['limit'] ? self::LIMIT_ARTICLES : $data['limit'], self::LIMIT_ARTICLES), 0); // at most 200; not specified/zero yields 200; negative values yield no limit + $tr = Arsse::$db->begin(); + // retrieve the list of label names for the user + $labels = []; + foreach (Arsse::$db->labelList(Arsse::$user->id, false) as $label) { + $labels[$label['id']] = $label['name']; + } + // retrieve the requested articles + $out = []; + try { + foreach ($this->fetchArticles($data, Database::LIST_FULL) as $article) { + $row = [ + 'id' => $article['id'], + 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null, + 'title' => $article['title'], + 'link' => $article['url'], + 'labels' => $this->articleLabelList($labels, $article['id']), + 'unread' => (bool) $article['unread'], + 'marked' => (bool) $article['starred'], + 'published' => false, // TODO: if the Published feed is implemented, the getHeadlines operation should be amended accordingly + 'author' => $article['author'], + 'updated' => Date::transform($article['edited_date'], "unix", "sql"), + 'is_updated' => ($article['published_date'] < $article['edited_date']), + 'feed_id' => (string) $article['subscription'], // string cast to be consistent with TTRSS + 'feed_title' => $article['subscription_title'], + 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API + 'note' => strlen((string) $article['note']) ? $article['note'] : null, + 'lang' => "", // FIXME: picoFeed should be able to retrieve this information + 'tags' => Arsse::$db->articleCategoriesGet(Arsse::$user->id, $article['id']), + 'comments_count' => 0, + 'comments_link' => "", + 'always_display_attachments' => false, + ]; + if ($data['show_content']) { + $row['content'] = $article['content']; + } + if ($data['show_excerpt']) { + // prepare an excerpt from the content + $text = strip_tags($article['content']); // get rid of all tags; elements with problematic content (e.g. script, style) should already be gone thanks to sanitization + $text = html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, "UTF-8"); + $text = trim($text); // trim whitespace at ends + $text = preg_replace("<\s+>s", " ", $text); // replace runs of whitespace with a single space + $row['excerpt'] = grapheme_substr($text, 0, self::LIMIT_EXCERPT).(grapheme_strlen($text) > self::LIMIT_EXCERPT ? "…" : ""); // add an ellipsis if the string is longer than N characters + } + if ($data['include_attachments']) { + $row['attachments'] = $article['media_url'] ? [[ + 'id' => (string) 0, // string cast to be consistent with TTRSS; nonsense ID because we don't use them for enclosures + 'content_url' => $article['media_url'], + 'content_type' => $article['media_type'], + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => (string) $article['id'], // string cast to be consistent with TTRSS + ]] : []; // TODO: We need to support multiple enclosures + } + $out[] = $row; + } + } catch (ExceptionInput $e) { + // ignore database errors (feeds/categories that don't exist) + // ensure that if using a header the database is not needlessly queried again + $data['skip'] = null; + } + if ($data['include_header']) { + if ($data['skip'] > 0 && $data['order_by'] != "date_reverse") { + // when paginating the header returns the latest ("first") item ID in the full list; we get this ID here + $data['skip'] = 0; + $data['limit'] = 1; + $firstID = ($this->fetchArticles($data, Database::LIST_MINIMAL)->getRow() ?? ['id' => 0])['id']; + } elseif ($data['order_by']=="date_reverse") { + // the "date_reverse" sort order doesn't get a first ID because it's meaningless for ascending-order pagination (pages doesn't go stale) + $firstID = 0; + } else { + // otherwise just use the ID of the first item in the list we've already computed + $firstID = ($out) ? $out[0]['id'] : 0; + } + // wrap the output with (but after) the header + $out = [ + [ + 'id' => $data['feed_id'], + 'is_cat' => $data['is_cat'] ?? false, + 'first_id' => $firstID, + ], + $out, + ]; + } + return $out; + } + + protected function fetchArticles(array $data, int $fields): \JKingWeb\Arsse\Db\Result { + // normalize input + if (is_null($data['feed_id'])) { + throw new Exception("INCORRECT_USAGE"); + } + $id = $data['feed_id']; + $cat = $data['is_cat'] ?? false; + $shallow = !($data['include_nested'] ?? false); + $viewMode = in_array($data['view_mode'], ["all_articles", "adaptive", "unread", "marked", "has_note", "published"]) ? $data['view_mode'] : "all_articles"; + // prepare the context; unsupported, invalid, or inherently empty contexts return synthetic empty result sets + $c = new Context; + $tr = Arsse::$db->begin(); + // start with the feed or category ID + if ($cat) { // categories + switch ($id) { + case self::CAT_SPECIAL: + // not valid + return new ResultEmpty; + case self::CAT_NOT_SPECIAL: + case self::CAT_ALL: + // no context needed here + break; + case self::CAT_UNCATEGORIZED: + // this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root + $c->folderShallow(0); + break; + case self::CAT_LABELS: + $c->labelled(true); + break; + default: + // any actual category + if ($shallow) { + $c->folderShallow($id); + } else { + $c->folder($id); + } + break; + } + } else { // feeds + if ($this->labelIn($id, false)) { // labels + $c->label($this->labelIn($id)); + } else { + switch ($id) { + case self::FEED_ARCHIVED: + // not implemented + return new ResultEmpty; + case self::FEED_STARRED: + $c->starred(true); + break; + case self::FEED_PUBLISHED: + // not implemented + // TODO: if the Published feed is implemented, the headline function needs to be modified accordingly + return new ResultEmpty; + case self::FEED_FRESH: + $c->modifiedSince(Date::sub("PT24H"))->unread(true); + break; + case self::FEED_ALL: + // no context needed here + break; + case self::FEED_READ: + $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one + break; + default: + // any actual feed + $c->subscription($id); + break; + } + } + } + // next handle the view mode + switch ($viewMode) { + case "all_articles": + // no context needed here + break; + case "adaptive": + // adaptive means "return only unread unless there are none, in which case return all articles" + if ($c->unread !== false && Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->unread(true))) { + $c->unread(true); + } + break; + case "unread": + if ($c->unread !== false) { + $c->unread(true); + } else { + // unread mode in the "Recently Read" feed is a no-op + return new ResultEmpty; + } + break; + case "marked": + $c->starred(true); + break; + case "has_note": + $c->annotated(true); + break; + case "published": + // not implemented + // TODO: if the Published feed is implemented, the headline function needs to be modified accordingly + return new ResultEmpty; + default: + throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore + } + // TODO: implement searching + // handle sorting + switch ($data['order_by']) { + case "date_reverse": + // sort oldest first + $c->reverse(false); + break; + case "feed_dates": + // sort newest first + $c->reverse(true); + break; + default: + // in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this + $c->reverse(true); + break; + } + // set the limit and offset + if ($data['limit'] > 0) { + $c->limit($data['limit']); + } + if ($data['skip'] > 0) { + $c->offset($data['skip']); + } + // set the minimum article ID + if ($data['since_id'] > 0) { + $c->oldestArticle($data['since_id'] + 1); + } + // return results + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + } +} diff --git a/lib/REST/TinyTinyRSS/Exception.php b/lib/REST/TinyTinyRSS/Exception.php new file mode 100644 index 00000000..b158deff --- /dev/null +++ b/lib/REST/TinyTinyRSS/Exception.php @@ -0,0 +1,21 @@ +data = $data; + parent::__construct($msg, 0, $e); + } + + public function getData(): array { + $err = ['error' => $this->getMessage()]; + return array_merge($err, $this->data, $err); + } +} diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php new file mode 100644 index 00000000..b31587f2 --- /dev/null +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -0,0 +1,36 @@ +method != "GET") { + // only GET requests are allowed + return new Response(405, "", "", ["Allow: GET"]); + } elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) { + return new Response(404); + } + $url = Arsse::$db->subscriptionFavicon((int) $match[1]); + if ($url) { + // strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL + if (($pos = strpos($url, "\r")) !== FALSE || ($pos = strpos($url, "\n")) !== FALSE) { + $url = substr($url, 0, $pos); + } + return new Response(301, "", "", ["Location: $url"]); + } else { + return new Response(404); + } + } +} \ No newline at end of file diff --git a/lib/Service.php b/lib/Service.php index b67c0d97..9dfed5b3 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -88,7 +88,10 @@ class Service { public static function cleanupPre(): bool { // mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period - return Arsse::$db->feedCleanup(); + Arsse::$db->feedCleanup(); + // delete expired log-in sessions + Arsse::$db->sessionCleanup(); + return true; } public static function cleanupPost(): bool { diff --git a/lib/User/ExceptionSession.php b/lib/User/ExceptionSession.php new file mode 100644 index 00000000..a7fdbee8 --- /dev/null +++ b/lib/User/ExceptionSession.php @@ -0,0 +1,10 @@ + 'Uncategorized', + 'API.TTRSS.Category.Special' => 'Special', + 'API.TTRSS.Category.Labels' => 'Labels', + 'API.TTRSS.Feed.All' => 'All articles', + 'API.TTRSS.Feed.Fresh' => 'Fresh articles', + 'API.TTRSS.Feed.Starred' => 'Starred articles', + 'API.TTRSS.Feed.Published' => 'Published articles', + 'API.TTRSS.Feed.Archived' => 'Archived articles', + 'API.TTRSS.Feed.Read' => 'Recently read', + 'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}', + 'Driver.Db.SQLite3.Name' => 'SQLite 3', 'Driver.Service.Curl.Name' => 'HTTP (curl)', 'Driver.Service.Internal.Name' => 'Internal', @@ -74,6 +85,8 @@ return [ 'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php', // this should not usually be encountered 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', + // indicates programming error + 'Exception.JKingWeb/Arsse/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, 1 {null} 2 {boolean} @@ -155,6 +168,7 @@ return [ }} other {Authenticated user is not authorized to perform the action "{action}" on behalf of {user}} }', + 'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist', 'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate', 'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid', 'Exception.JKingWeb/Arsse/Feed/Exception.maxRedirect' => 'Could not download feed "{url}" because its server reached its maximum number of HTTP redirections', diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql new file mode 100644 index 00000000..fe369420 --- /dev/null +++ b/sql/SQLite3/1.sql @@ -0,0 +1,48 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Sessions for Tiny Tiny RSS (and possibly others) +create table arsse_sessions ( + id text primary key, -- UUID of session + created text not null default CURRENT_TIMESTAMP, -- Session start timestamp + expires text not null, -- Time at which session is no longer valid + user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session +) without rowid; + +-- User-defined article labels for Tiny Tiny RSS +create table arsse_labels ( + id integer primary key, -- numeric ID + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user + name text not null, -- label text + modified text not null default CURRENT_TIMESTAMP, -- time at which the label was last modified + unique(owner,name) +); + +-- Labels assignments for articles +create table arsse_label_members ( + label integer not null references arsse_labels(id) on delete cascade, + article integer not null references arsse_articles(id) on delete cascade, + subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed + assigned boolean not null default 1, + modified text not null default CURRENT_TIMESTAMP, + primary key(label,article) +) without rowid; + +-- alter marks table to add Tiny Tiny RSS' notes +alter table arsse_marks rename to arsse_marks_old; +create table arsse_marks( + article integer not null references arsse_articles(id) on delete cascade, + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, + read boolean not null default 0, + starred boolean not null default 0, + modified text not null default CURRENT_TIMESTAMP, + note text not null default '', + primary key(article,subscription) +); +insert into arsse_marks(article,subscription,read,starred,modified) select article,subscription,read,starred,modified from arsse_marks_old; +drop table arsse_marks_old; + +-- set version marker +pragma user_version = 2; +update arsse_meta set value = '2' where key is 'schema_version'; \ No newline at end of file diff --git a/tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php b/tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php new file mode 100644 index 00000000..815bd490 --- /dev/null +++ b/tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php @@ -0,0 +1,10 @@ + */ +class TestDatabaseLabelSQLite3 extends Test\AbstractTest { + use Test\Database\Setup; + use Test\Database\DriverSQLite3; + use Test\Database\SeriesLabel; +} diff --git a/tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php b/tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php new file mode 100644 index 00000000..be10b889 --- /dev/null +++ b/tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php @@ -0,0 +1,10 @@ + */ +class TestDatabaseSessionSQLite3 extends Test\AbstractTest { + use Test\Database\Setup; + use Test\Database\DriverSQLite3; + use Test\Database\SeriesSession; +} diff --git a/tests/Db/TestResultAggregate.php b/tests/Db/TestResultAggregate.php new file mode 100644 index 00000000..433ef428 --- /dev/null +++ b/tests/Db/TestResultAggregate.php @@ -0,0 +1,101 @@ + */ +class TestResultAggregate extends Test\AbstractTest { + + public function testGetChangeCountAndLastInsertId() { + $in = [ + new Result([], 3, 4), + new Result([], 27, 10), + new Result([], 12, 2112), + ]; + $r = new Db\ResultAggregate(...$in); + $this->assertEquals(42, $r->changes()); + $this->assertEquals(2112, $r->lastId()); + } + + public function testIterateOverResults() { + $in = [ + new Result([['col' => 1]]), + new Result([['col' => 2]]), + new Result([['col' => 3]]), + ]; + $rows = []; + foreach (new Db\ResultAggregate(...$in) as $index => $row) { + $rows[$index] = $row['col']; + } + $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $rows); + } + + public function testIterateOverResultsTwice() { + $in = [ + new Result([['col' => 1]]), + new Result([['col' => 2]]), + new Result([['col' => 3]]), + ]; + $rows = []; + $test = new Db\ResultAggregate(...$in); + foreach ($test as $row) { + $rows[] = $row['col']; + } + $this->assertEquals([1,2,3], $rows); + $this->assertException("resultReused", "Db"); + foreach ($test as $row) { + $rows[] = $row['col']; + } + } + + public function testGetSingleValues() { + $test = new Db\ResultAggregate(...[ + new Result([['year' => 1867]]), + new Result([['year' => 1970]]), + new Result([['year' => 2112]]), + ]); + $this->assertEquals(1867, $test->getValue()); + $this->assertEquals(1970, $test->getValue()); + $this->assertEquals(2112, $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetFirstValuesOnly() { + $test = new Db\ResultAggregate(...[ + new Result([['year' => 1867, 'century' => 19]]), + new Result([['year' => 1970, 'century' => 20]]), + new Result([['year' => 2112, 'century' => 22]]), + ]); + $this->assertEquals(1867, $test->getValue()); + $this->assertEquals(1970, $test->getValue()); + $this->assertEquals(2112, $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetRows() { + $test = new Db\ResultAggregate(...[ + new Result([['album' => '2112', 'track' => '2112']]), + new Result([['album' => 'Clockwork Angels', 'track' => 'The Wreckers']]), + ]); + $rows = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $this->assertEquals($rows[0], $test->getRow()); + $this->assertEquals($rows[1], $test->getRow()); + $this->assertSame(null, $test->getRow()); + } + + public function testGetAllRows() { + $test = new Db\ResultAggregate(...[ + new Result([['album' => '2112', 'track' => '2112']]), + new Result([['album' => 'Clockwork Angels', 'track' => 'The Wreckers']]), + ]); + $rows = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $this->assertEquals($rows, $test->getAll()); + } +} \ No newline at end of file diff --git a/tests/Db/TestResultEmpty.php b/tests/Db/TestResultEmpty.php new file mode 100644 index 00000000..0a6cbe9f --- /dev/null +++ b/tests/Db/TestResultEmpty.php @@ -0,0 +1,37 @@ + */ +class TestResultEmpty extends Test\AbstractTest { + + public function testGetChangeCountAndLastInsertId() { + $r = new Db\ResultEmpty; + $this->assertEquals(0, $r->changes()); + $this->assertEquals(0, $r->lastId()); + } + + public function testIterateOverResults() { + $rows = []; + foreach (new Db\ResultEmpty as $index => $row) { + $rows[$index] = $row['col']; + } + $this->assertEquals([], $rows); + } + + public function testGetSingleValues() { + $test = new Db\ResultEmpty; + $this->assertSame(null, $test->getValue()); + } + + public function testGetRows() { + $test = new Db\ResultEmpty; + $this->assertSame(null, $test->getRow()); + } + + public function testGetAllRows() { + $test = new Db\ResultEmpty; + $rows = []; + $this->assertEquals($rows, $test->getAll()); + } +} \ No newline at end of file diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index c02dedc7..4f9b3300 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -28,19 +28,28 @@ class TestContext extends Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folderShallow' => 42, 'subscription' => 2112, 'article' => 255, 'edition' => 65535, + 'latestArticle' => 47, + 'oldestArticle' => 1337, 'latestEdition' => 47, 'oldestEdition' => 1337, 'unread' => true, 'starred' => true, 'modifiedSince' => new \DateTime(), 'notModifiedSince' => new \DateTime(), + 'markedSince' => new \DateTime(), + 'notMarkedSince' => new \DateTime(), 'editions' => [1,2], 'articles' => [1,2], + 'label' => 2112, + 'labelName' => "Rush", + 'labelled' => true, + 'annotated' => true, ]; - $times = ['modifiedSince','notModifiedSince']; + $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { if ($m->isConstructor() || $m->isStatic()) { diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index 29c9a813..70596a4d 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -83,6 +83,7 @@ class TestValueInfo extends Test\AbstractTest { [0.5, I::FLOAT], ["2.5", I::FLOAT], ["0.5", I::FLOAT], + [" 1 ", I::VALID], ]; foreach ($tests as $test) { list($value, $exp) = $test; @@ -322,7 +323,7 @@ class TestValueInfo extends Test\AbstractTest { For each of these types, there is an expected output value, as well as a boolean indicating whether the value should pass or fail a strict normalization. Conversion to DateTime is covered below by a different data set */ - /* Input value null bool int float string array */ + /* Input value null bool int float string array */ [null, [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], false]], ["", [null,true], [false,true], [0, false], [0.0, false], ["", true], [[""], false]], [1, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1], false]], @@ -434,7 +435,7 @@ class TestValueInfo extends Test\AbstractTest { } // DateTimeInterface tests $tests = [ - /* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */ + /* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */ [null, null, null, null, null, null, null, null, null, null, null, null, ], [$this->d("2010-01-01T00:00:00", 0, 0), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ], [$this->d("2010-01-01T00:00:00", 0, 1), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ], diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index b1ac0e46..2c42654d 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -500,7 +500,7 @@ class TestNCNV1_2 extends Test\AbstractTest { 'newestItemId' => 4758915, ]; Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->feeds['db'])); - Phake::when(Arsse::$db)->articleStarredCount(Arsse::$user->id)->thenReturn(0)->thenReturn(5); + Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); $exp = new Response(200, $exp1); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds"))); @@ -686,11 +686,11 @@ class TestNCNV1_2 extends Test\AbstractTest { ['lastModified' => $t->getTimestamp()], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context ]; - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything())->thenReturn($res); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42))->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112))->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1))->thenThrow(new ExceptionInput("typeViolation")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1))->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), Database::LIST_TYPICAL)->thenReturn($res); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(200, ['items' => $this->articles['rest']]); // check the contents of the response $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context @@ -711,23 +711,23 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->h->dispatch(new Request("GET", "/items", json_encode($in[10]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[11]), 'application/json')); // perform method verifications - Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6)); // offset is one more than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4)); // offset is one less than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->modifiedSince($t)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)); + Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), Database::LIST_TYPICAL); // offset is one more than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), Database::LIST_TYPICAL); // offset is one less than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->markedSince($t), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), Database::LIST_TYPICAL); } public function testMarkAFolderRead() { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read", $in, 'application/json'))); @@ -742,7 +742,7 @@ class TestNCNV1_2 extends Test\AbstractTest { public function testMarkASubscriptionRead() { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read", $in, 'application/json'))); @@ -757,7 +757,7 @@ class TestNCNV1_2 extends Test\AbstractTest { public function testMarkAllItemsRead() { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42); $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read", $in, 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=2112"))); @@ -771,13 +771,13 @@ class TestNCNV1_2 extends Test\AbstractTest { $unread = ['read' => false]; $star = ['starred' => true]; $unstar = ['starred' => false]; - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(1))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(1))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(42))->thenThrow(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(2))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(2))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(47))->thenThrow(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(3))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(3))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(2112))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/1/read"))); @@ -800,8 +800,6 @@ class TestNCNV1_2 extends Test\AbstractTest { $in = [ ["ook","eek","ack"], range(100, 199), - range(100, 149), - range(150, 199), ]; $inStar = $in; for ($a = 0; $a < sizeof($inStar); $a++) { @@ -809,11 +807,9 @@ class TestNCNV1_2 extends Test\AbstractTest { $inStar[$a][$b] = ['feedId' => 2112, 'guidHash' => $inStar[$a][$b]]; } } - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true); + Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions($in[1]))->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples - Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles($in[1]))->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple"))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple"))); @@ -836,27 +832,19 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); // ensure the data model was queried appropriately for read/unread - Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); - Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); - Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[1])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[2])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[3])); - Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $unread, (new Context)->editions([])); - Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[0])); - Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[1])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[2])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[3])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[1])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions([])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[0])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[1])); // ensure the data model was queried appropriately for star/unstar - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles([])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[0])); - Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[1])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[2])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[3])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles([])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[0])); - Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[1])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[2])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[3])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles([])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[0])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[1])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles([])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[0])); + Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[1])); } public function testQueryTheServerStatus() { diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php new file mode 100644 index 00000000..4d045469 --- /dev/null +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -0,0 +1,1784 @@ + + * @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */ +class TestTinyTinyAPI extends Test\AbstractTest { + protected $h; + protected $folders = [ + ['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"], + ['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"], + ['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], + ['id' => 2, 'parent' => 1, 'children' => 0, 'feeds' => 1, 'name' => "Rocketry"], + ['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], + ]; + protected $topFolders = [ + ['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], + ['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], + ]; + protected $subscriptions = [ + ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => " http://example.com/3", 'favicon' => 'http://example.com/3.png'], + ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => " http://example.com/4", 'favicon' => 'http://example.com/4.png'], + ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => " http://example.com/6", 'favicon' => 'http://example.com/6.png'], + ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => " http://example.com/1", 'favicon' => null], + ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => " http://example.com/5", 'favicon' => ''], + ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => " http://example.com/2", 'favicon' => 'http://example.com/2.png'], + ]; + protected $labels = [ + ['id' => 3, 'articles' => 100, 'read' => 94, 'unread' => 6, 'name' => "Fascinating"], + ['id' => 5, 'articles' => 0, 'read' => 0, 'unread' => 0, 'name' => "Interesting"], + ['id' => 1, 'articles' => 2, 'read' => 2, 'unread' => 0, 'name' => "Logical"], + ]; + protected $usedLabels = [ + ['id' => 3, 'articles' => 100, 'read' => 94, 'unread' => 6, 'name' => "Fascinating"], + ['id' => 1, 'articles' => 2, 'read' => 2, 'unread' => 0, 'name' => "Logical"], + ]; + protected $starred = ['total' => 10, 'unread' => 4, 'read' => 6]; + protected $articles = [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", + 'author' => '', + 'content' => '

Article content 1

', + 'guid' => '', + 'published_date' => '2000-01-01 00:00:00', + 'edited_date' => '2000-01-01 00:00:01', + 'modified_date' => '2000-01-01 01:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 101, + 'subscription' => 8, + 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', + 'media_url' => null, + 'media_type' => null, + 'note' => "", + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", + 'author' => 'J. King', + 'content' => '

Article content 2

', + 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', + 'published_date' => '2000-01-02 00:00:00', + 'edited_date' => '2000-01-02 00:00:02', + 'modified_date' => '2000-01-02 02:00:00', + 'unread' => 0, + 'starred' => 0, + 'edition' => 202, + 'subscription' => 8, + 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', + 'media_url' => "http://example.com/text", + 'media_type' => "text/plain", + 'note' => "Note 2", + ], + ]; + // text from https://corrigeur.fr/lorem-ipsum-traduction-origine.php + protected $richContent = << +

+ Pour vous faire mieux + connaitre d’ou\u{300} vient + l’erreur de ceux qui + bla\u{302}ment la + volupte\u{301}, et qui louent + en quelque sorte la douleur, + je vais entrer dans une + explication plus + e\u{301}tendue, et vous faire + voir tout ce qui a + e\u{301}te\u{301} dit + la\u{300}-dessus par + l’inventeur de la + ve\u{301}rite\u{301}, et, pour + ainsi dire, par l’architecte + de la vie heureuse. +

+ +LONG_STRING; + + protected function req($data) : Response { + return $this->h->dispatch(new Request("POST", "", json_encode($data))); + } + + protected function respGood($content = null, $seq = 0): Response { + return new Response(200, [ + 'seq' => $seq, + 'status' => 0, + 'content' => $content, + ]); + } + + protected function respErr(string $msg, $content = [], $seq = 0): Response { + $err = ['error' => $msg]; + return new Response(200, [ + 'seq' => $seq, + 'status' => 1, + 'content' => array_merge($err, $content, $err), + ]); + } + + protected function assertResponse(Response $exp, Response $act, string $text = null) { + $this->assertEquals($exp, $act, $text); + $this->assertSame($exp->payload, $act->payload, $text); + } + + public function setUp() { + $this->clearData(); + Arsse::$conf = new Conf(); + // create a mock user manager + Arsse::$user = Phake::mock(User::class); + Phake::when(Arsse::$user)->auth->thenReturn(true); + Phake::when(Arsse::$user)->rightsGet->thenReturn(100); + Arsse::$user->id = "john.doe@example.com"; + // create a mock database interface + Arsse::$db = Phake::mock(Database::class); + Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); + Phake::when(Arsse::$db)->sessionResume->thenThrow(new \JKingWeb\Arsse\User\ExceptionSession("invalid")); + Phake::when(Arsse::$db)->sessionResume("PriestsOfSyrinx")->thenReturn([ + 'id' => "PriestsOfSyrinx", + 'created' => "2000-01-01 00:00:00", + 'expires' => "2112-12-21 21:12:00", + 'user' => Arsse::$user->id, + ]); + $this->h = new REST\TinyTinyRSS\API(); + } + + public function tearDown() { + $this->clearData(); + } + + public function testHandleOptionsRequest() { + $exp = new Response(204, "", "", [ + "Allow: POST", + "Accept: application/json, text/json", + ]); + $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", ""))); + } + + public function testHandleInvalidData() { + $exp = $this->respErr("MALFORMED_INPUT", [], null); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", "This is not valid JSON data"))); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); // lack of data is also an error + } + + public function testLogIn() { + Phake::when(Arsse::$user)->auth(Arsse::$user->id, "superman")->thenReturn(false); + Phake::when(Arsse::$db)->sessionCreate->thenReturn("PriestsOfSyrinx")->thenReturn("SolarFederation"); + $data = [ + 'op' => "login", + 'user' => Arsse::$user->id, + 'password' => "secret", + ]; + $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $this->assertResponse($exp, $this->req($data)); + $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $this->assertResponse($exp, $this->req($data)); + // test a failed log-in + $data['password'] = "superman"; + $exp = $this->respErr("LOGIN_ERROR"); + $this->assertResponse($exp, $this->req($data)); + // logging in should never try to resume a session + Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); + } + + public function testHandleGenericError() { + Phake::when(Arsse::$user)->auth(Arsse::$user->id, $this->anything())->thenThrow(new \JKingWeb\Arsse\Db\ExceptionTimeout("general")); + $data = [ + 'op' => "login", + 'user' => Arsse::$user->id, + 'password' => "secret", + ]; + $exp = new Response(500); + $this->assertResponse($exp, $this->req($data)); + } + + public function testLogOut() { + Phake::when(Arsse::$db)->sessionDestroy->thenReturn(true); + $data = [ + 'op' => "logout", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => "OK"]); + $this->assertResponse($exp, $this->req($data)); + Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); + } + + public function testValidateASession() { + $data = [ + 'op' => "isLoggedIn", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => true]); + $this->assertResponse($exp, $this->req($data)); + $data['sid'] = "SolarFederation"; + $exp = $this->respErr("NOT_LOGGED_IN"); + $this->assertResponse($exp, $this->req($data)); + } + + public function testHandleUnknownMethods() { + $exp = $this->respErr("UNKNOWN_METHOD", ['method' => "thisMethodDoesNotExist"]); + $data = [ + 'op' => "thisMethodDoesNotExist", + 'sid' => "PriestsOfSyrinx", + ]; + $this->assertResponse($exp, $this->req($data)); + } + + public function testHandleMixedCaseMethods() { + $data = [ + 'op' => "isLoggedIn", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => true]); + $this->assertResponse($exp, $this->req($data)); + $data['op'] = "isloggedin"; + $this->assertResponse($exp, $this->req($data)); + $data['op'] = "ISLOGGEDIN"; + $this->assertResponse($exp, $this->req($data)); + $data['op'] = "iSlOgGeDiN"; + $this->assertResponse($exp, $this->req($data)); + } + + public function testRetrieveServerVersion() { + $data = [ + 'op' => "getVersion", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood([ + 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, + 'arsse_version' => Arsse::VERSION, + ]); + $this->assertResponse($exp, $this->req($data)); + } + + public function testRetrieveProtocolLevel() { + $data = [ + 'op' => "getApiLevel", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $this->assertResponse($exp, $this->req($data)); + } + + public function testAddACategory() { + $in = [ + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 1], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 2112], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx"], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => ""], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => " "], + ]; + $db = [ + ['name' => "Software", 'parent' => null], + ['name' => "Hardware", 'parent' => 1], + ['name' => "Hardware", 'parent' => 2112], + ]; + $out = [ + ['id' => 2, 'name' => "Software", 'parent' => null], + ['id' => 3, 'name' => "Hardware", 'parent' => 1], + ['id' => 1, 'name' => "Politics", 'parent' => null], + ]; + // set of various mocks for testing + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call + Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([$out[0], $out[2]])); + Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result([$out[1]])); + // set up mocks that produce errors + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[2])->thenThrow(new ExceptionInput("idMissing")); // parent folder does not exist + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => "", 'parent' => null])->thenThrow(new ExceptionInput("missing")); + Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); + // correctly add two folders + $exp = $this->respGood("2"); + $this->assertResponse($exp, $this->req($in[0])); + $exp = $this->respGood("3"); + $this->assertResponse($exp, $this->req($in[1])); + // attempt to add the two folders again + $exp = $this->respGood("2"); + $this->assertResponse($exp, $this->req($in[0])); + $exp = $this->respGood("3"); + $this->assertResponse($exp, $this->req($in[1])); + Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); + Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); + // add a folder to a missing parent (silently fails) + $exp = $this->respGood(false); + $this->assertResponse($exp, $this->req($in[2])); + // add some invalid folders + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + } + + public function testRemoveACategory() { + $in = [ + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112], + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1], + ]; + Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a folder + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // try deleting it again (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // delete a folder which does not exist (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // delete an invalid folder (causes an error) + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[2])); + Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); + } + + public function testMoveACategory() { + $in = [ + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'parent_id' => 2], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 0], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 47], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'parent_id' => 1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => -1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'parent_id' => -1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['parent' => 1]], + [Arsse::$user->id, 2112, ['parent' => 2]], + [Arsse::$user->id, 42, ['parent' => 0]], + [Arsse::$user->id, 42, ['parent' => 47]], + [Arsse::$user->id, -1, ['parent' => 1]], + [Arsse::$user->id, 42, ['parent' => -1]], + [Arsse::$user->id, 42, ['parent' => 0]], + [Arsse::$user->id, 0, ['parent' => -1]], + [Arsse::$user->id, 0, ['parent' => 0]], + ]; + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[4])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[6])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[7])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); + // succefully move a folder + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // move a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // move a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[6])); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[7])); + $this->assertResponse($exp, $this->req($in[8])); + Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRenameACategory() { + $in = [ + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'caption' => "Eek"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Eek"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => ""], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => " "], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['name' => "Ook"]], + [Arsse::$user->id, 2112, ['name' => "Eek"]], + [Arsse::$user->id, 42, ['name' => "Eek"]], + ]; + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully rename a folder + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // rename a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // rename a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[2])); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[6])); + $this->assertResponse($exp, $this->req($in[7])); + $this->assertResponse($exp, $this->req($in[8])); + Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testAddASubscription() { + $in = [ + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/0"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/1", 'category_id' => 42], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/2", 'category_id' => 2112], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/3"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Valid"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Invalid"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/6"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/7"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/8", 'category_id' => 47], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/9", 'category_id' => 1], + // these don't even query the database as the input is syntactically invalid + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx"], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => []], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => "", 'password' => []], + ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'category_id' => -1], + ]; + $db = [ + [Arsse::$user->id, "http://example.com/0", "", ""], + [Arsse::$user->id, "http://example.com/1", "", ""], + [Arsse::$user->id, "http://example.com/2", "", ""], + [Arsse::$user->id, "http://example.com/3", "", ""], + [Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Valid", "", ""], + [Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Invalid", "", ""], + [Arsse::$user->id, "http://example.com/6", "", ""], + [Arsse::$user->id, "http://example.com/7", "", ""], + [Arsse::$user->id, "http://example.com/8", "", ""], + [Arsse::$user->id, "http://example.com/9", "", ""], + ]; + $out = [ + ['code' => 1, 'feed_id' => 2], + ['code' => 5, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", new \PicoFeed\Client\UnauthorizedException()))->getMessage()], + ['code' => 1, 'feed_id' => 0], + ['code' => 0, 'feed_id' => 3], + ['code' => 0, 'feed_id' => 1], + ['code' => 3, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://localhost:8000/Feed/Discovery/Invalid", new \PicoFeed\Reader\SubscriptionNotFoundException()))->getMessage()], + ['code' => 2, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", new \PicoFeed\Client\InvalidUrlException()))->getMessage()], + ['code' => 6, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()))->getMessage()], + ['code' => 1, 'feed_id' => 4], + ['code' => 0, 'feed_id' => 4], + ]; + $list = [ + ['id' => 1, 'url' => "http://localhost:8000/Feed/Discovery/Feed"], + ['id' => 2, 'url' => "http://example.com/0"], + ['id' => 3, 'url' => "http://example.com/3"], + ['id' => 4, 'url' => "http://example.com/9"], + ]; + Phake::when(Arsse::$db)->subscriptionAdd(...$db[0])->thenReturn(2); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[1])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", new \PicoFeed\Client\UnauthorizedException())); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[2])->thenReturn(2); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[4])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[5])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[6])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", new \PicoFeed\Client\InvalidUrlException())); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException())); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[8])->thenReturn(4); + Phake::when(Arsse::$db)->subscriptionAdd(...$db[9])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn(['id' => 42]); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 47)->thenReturn(['id' => 47]); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 4, $this->anything())->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($list)); + for ($a = 0; $a < (sizeof($in) - 4); $a++) { + $exp = $this->respGood($out[$a]); + $this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); + } + $exp = $this->respErr("INCORRECT_USAGE"); + for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) { + $this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); + } + Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]); + } + + public function testRemoveASubscription() { + $in = [ + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"], + ]; + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a folder + $exp = $this->respGood(['status' => "OK"]); + $this->assertResponse($exp, $this->req($in[0])); + // try deleting it again (this should noisily fail, as should everything else) + $exp = $this->respErr("FEED_NOT_FOUND"); + $this->assertResponse($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); + } + + public function testMoveASubscription() { + $in = [ + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'category_id' => 2], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 0], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 47], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'category_id' => 1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => -1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'category_id' => -1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['folder' => 1]], + [Arsse::$user->id, 2112, ['folder' => 2]], + [Arsse::$user->id, 42, ['folder' => 0]], + [Arsse::$user->id, 42, ['folder' => 47]], + ]; + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully move a subscription + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // move a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // move a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[6])); + $this->assertResponse($exp, $this->req($in[7])); + $this->assertResponse($exp, $this->req($in[8])); + Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRenameASubscription() { + $in = [ + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'caption' => "Eek"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Eek"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => ""], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => " "], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['name' => "Ook"]], + [Arsse::$user->id, 2112, ['name' => "Eek"]], + [Arsse::$user->id, 42, ['name' => "Eek"]], + ]; + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully rename a subscription + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // rename a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // rename a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[2])); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[6])); + $this->assertResponse($exp, $this->req($in[7])); + $this->assertResponse($exp, $this->req($in[8])); + Phake::verify(Arsse::$db, Phake::times(3))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRetrieveTheGlobalUnreadCount() { + $in = ['op' => "getUnread", 'sid' => "PriestsOfSyrinx"]; + Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'unread' => 2112], + ['id' => 2, 'unread' => 42], + ['id' => 3, 'unread' => 47], + ])); + $exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]); + $this->assertResponse($exp, $this->req($in)); + } + + public function testRetrieveTheServerConfiguration() { + $in = ['op' => "getConfig", 'sid' => "PriestsOfSyrinx"]; + $interval = Service::interval(); + $valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval); + $invalid = $valid->sub($interval)->sub($interval); + Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql")); + Phake::when(Arsse::$db)->subscriptionCount(Arsse::$user->id)->thenReturn(12)->thenReturn(2); + $exp = [ + ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12], + ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2], + ]; + $this->assertResponse($this->respGood($exp[0]), $this->req($in)); + $this->assertResponse($this->respGood($exp[1]), $this->req($in)); + } + + public function testUpdateAFeed() { + $in = [ + ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 1], + ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2], + ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx"], + ]; + Phake::when(Arsse::$db)->feedUpdate(11)->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn(['id' => 1, 'feed' => 11]); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing")); + $exp = $this->respGood(['status' => "OK"]); + $this->assertResponse($exp, $this->req($in[0])); + Phake::verify(Arsse::$db)->feedUpdate(11); + $exp = $this->respErr("FEED_NOT_FOUND"); + $this->assertResponse($exp, $this->req($in[1])); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + } + + public function testAddALabel() { + $in = [ + ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"], + ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware",], + ['op' => "addLabel", 'sid' => "PriestsOfSyrinx"], + ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => ""], + ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => " "], + ]; + $db = [ + ['name' => "Software"], + ['name' => "Hardware"], + ]; + $out = [ + ['id' => 2, 'name' => "Software"], + ['id' => 3, 'name' => "Hardware"], + ['id' => 1, 'name' => "Politics"], + ]; + // set of various mocks for testing + Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call + Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call + Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true)->thenReturn($out[0]); + Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true)->thenReturn($out[1]); + // set up mocks that produce errors + Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); + Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing")); + Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); + // correctly add two labels + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); + $this->assertResponse($exp, $this->req($in[0])); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); + $this->assertResponse($exp, $this->req($in[1])); + // attempt to add the two labels again + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); + $this->assertResponse($exp, $this->req($in[0])); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); + $this->assertResponse($exp, $this->req($in[1])); + Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true); + Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true); + // add some invalid labels + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + } + + public function testRemoveALabel() { + $in = [ + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042], + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112], + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 1], + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 0], + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -10], + ]; + Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a label + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // try deleting it again (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // delete a label which does not exist (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // delete some invalid labels (causes an error) + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 18); + Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 1088); + } + + public function testRenameALabel() { + $in = [ + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'caption' => "Eek"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => "Eek"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => ""], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => " "], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1, 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 18, ['name' => "Ook"]], + [Arsse::$user->id, 1088, ['name' => "Eek"]], + [Arsse::$user->id, 18, ['name' => "Eek"]], + [Arsse::$user->id, 18, ['name' => ""]], + [Arsse::$user->id, 18, ['name' => " "]], + [Arsse::$user->id, 18, ['name' => ""]], + ]; + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[4])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); + // succefully rename a label + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[0])); + // rename a label which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[1])); + // rename a label causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertResponse($exp, $this->req($in[2])); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[6])); + $this->assertResponse($exp, $this->req($in[7])); + $this->assertResponse($exp, $this->req($in[8])); + Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRetrieveCategoryLists() { + $in = [ + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'include_empty' => true], + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx"], + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'unread_only' => true], + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true, 'include_empty' => true], + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true], + ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true, 'unread_only' => true], + ]; + Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->folders)); + Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->topFolders)); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + $exp = [ + [ + ['id' => "5", 'title' => "Local", 'unread' => 10, 'order_id' => 1], + ['id' => "6", 'title' => "National", 'unread' => 18, 'order_id' => 2], + ['id' => "4", 'title' => "Photography", 'unread' => 0, 'order_id' => 3], + ['id' => "3", 'title' => "Politics", 'unread' => 0, 'order_id' => 4], + ['id' => "2", 'title' => "Rocketry", 'unread' => 5, 'order_id' => 5], + ['id' => "1", 'title' => "Science", 'unread' => 2, 'order_id' => 6], + ['id' => 0, 'title' => "Uncategorized", 'unread' => 0], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + [ + ['id' => "5", 'title' => "Local", 'unread' => 10, 'order_id' => 1], + ['id' => "6", 'title' => "National", 'unread' => 18, 'order_id' => 2], + ['id' => "3", 'title' => "Politics", 'unread' => 0, 'order_id' => 4], + ['id' => "2", 'title' => "Rocketry", 'unread' => 5, 'order_id' => 5], + ['id' => "1", 'title' => "Science", 'unread' => 2, 'order_id' => 6], + ['id' => 0, 'title' => "Uncategorized", 'unread' => 0], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + [ + ['id' => "5", 'title' => "Local", 'unread' => 10, 'order_id' => 1], + ['id' => "6", 'title' => "National", 'unread' => 18, 'order_id' => 2], + ['id' => "2", 'title' => "Rocketry", 'unread' => 5, 'order_id' => 5], + ['id' => "1", 'title' => "Science", 'unread' => 2, 'order_id' => 6], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + [ + ['id' => "4", 'title' => "Photography", 'unread' => 0, 'order_id' => 1], + ['id' => "3", 'title' => "Politics", 'unread' => 28, 'order_id' => 2], + ['id' => "1", 'title' => "Science", 'unread' => 7, 'order_id' => 3], + ['id' => 0, 'title' => "Uncategorized", 'unread' => 0], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + [ + ['id' => "3", 'title' => "Politics", 'unread' => 28, 'order_id' => 2], + ['id' => "1", 'title' => "Science", 'unread' => 7, 'order_id' => 3], + ['id' => 0, 'title' => "Uncategorized", 'unread' => 0], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + [ + ['id' => "3", 'title' => "Politics", 'unread' => 28, 'order_id' => 2], + ['id' => "1", 'title' => "Science", 'unread' => 7, 'order_id' => 3], + ['id' => -1, 'title' => "Special", 'unread' => 11], + ['id' => -2, 'title' => "Labels", 'unread' => "6"], + ], + ]; + for ($a = 0; $a < sizeof($in); $a++) { + $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); + } + } + + public function testRetrieveCounterList() { + $in = ['op' => "getCounters", 'sid' => "PriestsOfSyrinx"]; + Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->folders)); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + $exp = [ + ['id' => "global-unread", 'counter' => 35], + ['id' => "subscribed-feeds", 'counter' => 6], + ['id' => 0, 'counter' => 0, 'auxcounter' => 0], + ['id' => -1, 'counter' => 4, 'auxcounter' => 10], + ['id' => -2, 'counter' => 0, 'auxcounter' => 0], + ['id' => -3, 'counter' => 7, 'auxcounter' => 0], + ['id' => -4, 'counter' => 35, 'auxcounter' => 0], + ['id' => -1027, 'counter' => 6, 'auxcounter' => 100], + ['id' => -1025, 'counter' => 0, 'auxcounter' => 2], + ['id' => "3", 'updated' => "2016-05-23T06:40:02", 'counter' => 2, 'has_img' => 1], + ['id' => "4", 'updated' => "2017-10-09T15:58:34", 'counter' => 6, 'has_img' => 1], + ['id' => "1", 'updated' => "2017-09-15T22:54:16", 'counter' => 5, 'has_img' => 0], + ['id' => "5", 'updated' => "2017-07-07T17:07:17", 'counter' => 12, 'has_img' => 0], + ['id' => "2", 'updated' => "2011-11-11T11:11:11", 'counter' => 10, 'has_img' => 1], + ['id' => 5, 'kind' => "cat", 'counter' => 10], + ['id' => 6, 'kind' => "cat", 'counter' => 18], + ['id' => 3, 'kind' => "cat", 'counter' => 28], + ['id' => 2, 'kind' => "cat", 'counter' => 5], + ['id' => 1, 'kind' => "cat", 'counter' => 7], + ['id' => -2, 'kind' => "cat", 'counter' => 6], + ]; + $this->assertResponse($this->respGood($exp), $this->req($in)); + } + + public function testRetrieveTheLabelList() { + $in = [ + ['op' => "getLabels", 'sid' => "PriestsOfSyrinx"], + ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 1], + ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 2], + ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 3], + ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 4], + ]; + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 1)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2)->thenReturn([3]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 3)->thenReturn([]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 4)->thenThrow(new ExceptionInput("idMissing")); + $exp = [ + [ + ['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1029, 'caption' => "Interesting", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ], + [ + ['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => true], + ['id' => -1029, 'caption' => "Interesting", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => true], + ], + [ + ['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => true], + ['id' => -1029, 'caption' => "Interesting", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ], + [ + ['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1029, 'caption' => "Interesting", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ], + [ + ['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1029, 'caption' => "Interesting", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => false], + ], + ]; + for ($a = 0; $a < sizeof($in); $a++) { + $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); + } + } + + public function testAssignArticlesToALabel() { + $list = [ + range(1,100), + range(1,50), + range(51,100), + ]; + $in = [ + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'article_ids' => implode(",", $list[0])], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'article_ids' => implode(",", $list[0]), 'assign' => true], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 42], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 0], + ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx"], + ]; + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true)->thenReturn(42); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true)->thenReturn(47); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); + $exp = $this->respGood(['status' => "OK", 'updated' => 89]); + $this->assertResponse($exp, $this->req($in[0])); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); + $exp = $this->respGood(['status' => "OK", 'updated' => 7]); + $this->assertResponse($exp, $this->req($in[1])); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); + $exp = $this->respGood(['status' => "OK", 'updated' => 0]); + $this->assertResponse($exp, $this->req($in[2])); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[6])); + } + + public function testRetrieveFeedTree() { + $in = [ + ['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx", 'include_empty' => true], + ['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx"], + ]; + Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->folders)); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->labelList($this->anything(), true)->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + // the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them + $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Photography','id'=>'CAT:4','bare_id'=>4,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(0 feeds)','items'=>[],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; + $this->assertResponse($this->respGood($exp), $this->req($in[0])); + $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; + $this->assertResponse($this->respGood($exp), $this->req($in[1])); + } + + public function testMarkFeedsAsRead() { + $in1 = [ + // no-ops + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx"], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true], + ]; + $in2 = [ + // simple contexts + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], + ]; + $in3 = [ + // this one has a tricky time-based context + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], + ]; + Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation")); + $exp = $this->respGood(['status' => "OK"]); + // verify the above are in fact no-ops + for ($a = 0; $a < sizeof($in1); $a++) { + $this->assertResponse($exp, $this->req($in1[$a]), "Test $a failed"); + } + Phake::verify(Arsse::$db, Phake::times(0))->articleMark; + // verify the simple contexts + for ($a = 0; $a < sizeof($in2); $a++) { + $this->assertResponse($exp, $this->req($in2[$a]), "Test $a failed"); + } + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1088)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(2112)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folderShallow(0)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->labelled(true)); + // verify the time-based mock + $t = Date::sub("PT24H"); + for ($a = 0; $a < sizeof($in3); $a++) { + $this->assertResponse($exp, $this->req($in3[$a]), "Test $a failed"); + } + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); + } + + public function testRetrieveFeedList() { + $in1 = [ + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx"], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -1], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -1, 'unread_only' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -2], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -2, 'unread_only' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -3], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -3, 'unread_only' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -4], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -4, 'unread_only' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => 1], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => 1, 'offset' => 1], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 1], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 1, 'include_nested' => true], + ]; + $in2 = [ + // these should all return an empty list + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 0, 'unread_only' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 2112], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 2112, 'include_nested' => true], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => -42], + ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'offset' => 2], + ]; + // statistical mocks + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + Phake::when(Arsse::$db)->articleCount->thenReturn(7); // FIXME: this should check an unread+modifiedSince context + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(35); + // label mocks + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + // subscription and folder list and unread count mocks + Phake::when(Arsse::$db)->folderList->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionList->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->folders)); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, true)->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->filterSubs(null))); + Phake::when(Arsse::$db)->folderList($this->anything(), null)->thenReturn(new Result($this->folders)); + Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->filterFolders(null))); + foreach ($this->folders as $f) { + Phake::when(Arsse::$db)->folderList($this->anything(), $f['id'], false)->thenReturn(new Result($this->filterFolders($f['id']))); + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->folder($f['id']))->thenReturn($this->reduceFolders($f['id'])); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), $f['id'], false)->thenReturn(new Result($this->filterSubs($f['id']))); + } + $exp = [ + [ + ['id' => 6, 'title' => 'Eurogamer', 'unread' => 0, 'cat_id' => 0, 'feed_url' => " http://example.com/6", 'has_icon' => true, 'last_updated' => 1266005327, 'order_id' => 1], + ], + [ + ['id' => -1, 'title' => "Starred articles", 'unread' => "4", 'cat_id' => -1], + ['id' => -2, 'title' => "Published articles", 'unread' => "0", 'cat_id' => -1], + ['id' => -3, 'title' => "Fresh articles", 'unread' => "7", 'cat_id' => -1], + ['id' => -4, 'title' => "All articles", 'unread' => "35", 'cat_id' => -1], + ['id' => -6, 'title' => "Recently read", 'unread' => 0, 'cat_id' => -1], + ['id' => 0, 'title' => "Archived articles", 'unread' => "0", 'cat_id' => -1], + ], + [ + ['id' => -1, 'title' => "Starred articles", 'unread' => "4", 'cat_id' => -1], + ['id' => -3, 'title' => "Fresh articles", 'unread' => "7", 'cat_id' => -1], + ['id' => -4, 'title' => "All articles", 'unread' => "35", 'cat_id' => -1], + ], + [ + ['id' => -1027, 'title' => "Fascinating", 'unread' => "6", 'cat_id' => -2], + ['id' => -1025, 'title' => "Logical", 'unread' => "0", 'cat_id' => -2], + ], + [ + ['id' => -1027, 'title' => "Fascinating", 'unread' => "6", 'cat_id' => -2], + ], + [ + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 2], + ['id' => 6, 'title' => 'Eurogamer', 'unread' => 0, 'cat_id' => 0, 'feed_url' => " http://example.com/6", 'has_icon' => true, 'last_updated' => 1266005327, 'order_id' => 3], + ['id' => 1, 'title' => 'NASA JPL', 'unread' => 5, 'cat_id' => 2, 'feed_url' => " http://example.com/1", 'has_icon' => false, 'last_updated' => 1505516056, 'order_id' => 4], + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 5], + ['id' => 2, 'title' => 'Toronto Star', 'unread' => 10, 'cat_id' => 5, 'feed_url' => " http://example.com/2", 'has_icon' => true, 'last_updated' => 1321009871, 'order_id' => 6], + ], + [ + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 2], + ['id' => 1, 'title' => 'NASA JPL', 'unread' => 5, 'cat_id' => 2, 'feed_url' => " http://example.com/1", 'has_icon' => false, 'last_updated' => 1505516056, 'order_id' => 4], + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 5], + ['id' => 2, 'title' => 'Toronto Star', 'unread' => 10, 'cat_id' => 5, 'feed_url' => " http://example.com/2", 'has_icon' => true, 'last_updated' => 1321009871, 'order_id' => 6], + ], + [ + ['id' => -1027, 'title' => "Fascinating", 'unread' => "6", 'cat_id' => -2], + ['id' => -1025, 'title' => "Logical", 'unread' => "0", 'cat_id' => -2], + ['id' => -1, 'title' => "Starred articles", 'unread' => "4", 'cat_id' => -1], + ['id' => -2, 'title' => "Published articles", 'unread' => "0", 'cat_id' => -1], + ['id' => -3, 'title' => "Fresh articles", 'unread' => "7", 'cat_id' => -1], + ['id' => -4, 'title' => "All articles", 'unread' => "35", 'cat_id' => -1], + ['id' => -6, 'title' => "Recently read", 'unread' => 0, 'cat_id' => -1], + ['id' => 0, 'title' => "Archived articles", 'unread' => "0", 'cat_id' => -1], + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 2], + ['id' => 6, 'title' => 'Eurogamer', 'unread' => 0, 'cat_id' => 0, 'feed_url' => " http://example.com/6", 'has_icon' => true, 'last_updated' => 1266005327, 'order_id' => 3], + ['id' => 1, 'title' => 'NASA JPL', 'unread' => 5, 'cat_id' => 2, 'feed_url' => " http://example.com/1", 'has_icon' => false, 'last_updated' => 1505516056, 'order_id' => 4], + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 5], + ['id' => 2, 'title' => 'Toronto Star', 'unread' => 10, 'cat_id' => 5, 'feed_url' => " http://example.com/2", 'has_icon' => true, 'last_updated' => 1321009871, 'order_id' => 6], + ], + [ + ['id' => -1027, 'title' => "Fascinating", 'unread' => "6", 'cat_id' => -2], + ['id' => -1, 'title' => "Starred articles", 'unread' => "4", 'cat_id' => -1], + ['id' => -3, 'title' => "Fresh articles", 'unread' => "7", 'cat_id' => -1], + ['id' => -4, 'title' => "All articles", 'unread' => "35", 'cat_id' => -1], + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 2], + ['id' => 1, 'title' => 'NASA JPL', 'unread' => 5, 'cat_id' => 2, 'feed_url' => " http://example.com/1", 'has_icon' => false, 'last_updated' => 1505516056, 'order_id' => 4], + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 5], + ['id' => 2, 'title' => 'Toronto Star', 'unread' => 10, 'cat_id' => 5, 'feed_url' => " http://example.com/2", 'has_icon' => true, 'last_updated' => 1321009871, 'order_id' => 6], + ], + [ + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 1], + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 2], + ], + [ + ['id' => 4, 'title' => 'CBC News', 'unread' => 6, 'cat_id' => 6, 'feed_url' => " http://example.com/4", 'has_icon' => true, 'last_updated' => 1507564714, 'order_id' => 1], + ], + [ + ['id' => 5, 'title' => 'Ottawa Citizen', 'unread' => 12, 'cat_id' => 6, 'feed_url' => " http://example.com/5", 'has_icon' => false, 'last_updated' => 1499447237, 'order_id' => 2], + ], + [ + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ], + [ + ['id' => 2, 'title' => "Rocketry", 'unread' => 5, 'is_cat' => true, 'order_id' => 1], + ['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1], + ], + ]; + for ($a = 0; $a < sizeof($in1); $a++) { + $this->assertResponse($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + $this->assertResponse($this->respGood([]), $this->req($in2[$a]), "Test $a failed"); + } + } + + protected function filterFolders(int $id = null): array { + return array_filter($this->folders, function($value) use ($id) {return $value['parent']==$id;}); + } + + protected function filterSubs(int $folder = null): array { + return array_filter($this->subscriptions, function($value) use ($folder) {return $value['folder']==$folder;}); + } + + protected function reduceFolders(int $id = null) : int { + $out = 0; + foreach ($this->filterFolders($id) as $f) { + $out += $this->reduceFolders($f['id']); + } + $out += array_reduce(array_filter($this->subscriptions, function($value) use ($id) {return $value['folder']==$id;}), function($sum, $value) {return $sum + $value['unread'];}, 0); + return $out; + } + + public function testChangeArticles() { + $in = [ + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx"], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1"], + + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 0], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 1], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 2], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 3], // invalid mode + + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1], // Published feed' no-op + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 0], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 1], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 2], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 3], // invalid mode + + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 0], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 1], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 2], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 3], // invalid mode + + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 0], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 1], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 2], + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 3], // invalid mode + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'data' => "eh"], + + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 4], // invalid field + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "0, -1", 'field' => 3], // no valid IDs + ]; + Phake::when(Arsse::$db)->articleMark->thenReturn(1); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42, 2112]))->thenReturn(2); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => true], (new Context)->articles([42, 2112]))->thenReturn(4); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42, 2112])->starred(true))->thenReturn(8); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => true], (new Context)->articles([42, 2112])->starred(false))->thenReturn(16); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->articles([42, 2112]))->thenReturn(32); // false is read for TT-RSS + Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => false], (new Context)->articles([42, 2112]))->thenReturn(64); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->articles([42, 2112])->unread(true))->thenReturn(128); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => false], (new Context)->articles([42, 2112])->unread(false))->thenReturn(256); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['note' => ""], (new Context)->articles([42, 2112]))->thenReturn(512); + Phake::when(Arsse::$db)->articleMark($this->anything(), ['note' => "eh"], (new Context)->articles([42, 2112]))->thenReturn(1024); + $out = [ + $this->respErr("INCORRECT_USAGE"), + $this->respGood(['status' => "OK", 'updated' => 2]), + + $this->respGood(['status' => "OK", 'updated' => 2]), + $this->respGood(['status' => "OK", 'updated' => 2]), + $this->respGood(['status' => "OK", 'updated' => 4]), + $this->respGood(['status' => "OK", 'updated' => 24]), + $this->respErr("INCORRECT_USAGE"), + + $this->respGood(['status' => "OK", 'updated' => 0]), + $this->respGood(['status' => "OK", 'updated' => 0]), + $this->respGood(['status' => "OK", 'updated' => 0]), + $this->respGood(['status' => "OK", 'updated' => 0]), + $this->respErr("INCORRECT_USAGE"), + + $this->respGood(['status' => "OK", 'updated' => 32]), + $this->respGood(['status' => "OK", 'updated' => 32]), + $this->respGood(['status' => "OK", 'updated' => 64]), + $this->respGood(['status' => "OK", 'updated' => 384]), + $this->respErr("INCORRECT_USAGE"), + + $this->respGood(['status' => "OK", 'updated' => 512]), + $this->respGood(['status' => "OK", 'updated' => 512]), + $this->respGood(['status' => "OK", 'updated' => 512]), + $this->respGood(['status' => "OK", 'updated' => 512]), + $this->respGood(['status' => "OK", 'updated' => 512]), + $this->respGood(['status' => "OK", 'updated' => 1024]), + + $this->respErr("INCORRECT_USAGE"), + $this->respErr("INCORRECT_USAGE"), + ]; + for ($a = 0; $a < sizeof($in); $a++) { + $this->assertResponse($out[$a], $this->req($in[$a]), "Test $a failed"); + } + } + + public function testListArticles() { + $in = [ + // error conditions + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => 0], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => -1], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "0,-1"], + // acceptable input + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101,102"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "102"], + ]; + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 101)->thenReturn([]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 102)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]))->thenReturn(new Result($this->articles)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result([$this->articles[0]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result([$this->articles[1]])); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + $exp = [ + [ + 'id' => "101", + 'guid' => null, + 'title' => 'Article title 1', + 'link' => 'http://example.com/1', + 'labels' => [], + 'unread' => true, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => '', + 'updated' => strtotime('2000-01-01 00:00:01'), + 'feed_id' => "8", + 'feed_title' => "Feed 11", + 'attachments' => [], + 'score' => 0, + 'note' => null, + 'lang' => "", + 'content' => '

Article content 1

', + ], + [ + 'id' => "102", + 'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7", + 'title' => 'Article title 2', + 'link' => 'http://example.com/2', + 'labels' => [ + [-1025, "Logical", "", ""], + [-1027, "Fascinating", "", ""], + ], + 'unread' => false, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => "J. King", + 'updated' => strtotime('2000-01-02 00:00:02'), + 'feed_id' => "8", + 'feed_title' => "Feed 11", + 'attachments' => [ + [ + 'id' => "0", + 'content_url' => "http://example.com/text", + 'content_type' => "text/plain", + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => "102", + ], + ], + 'score' => 0, + 'note' => "Note 2", + 'lang' => "", + 'content' => '

Article content 2

', + ], + ]; + $this->assertResponse($this->respGood($exp), $this->req($in[4])); + $this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); + $this->assertResponse($this->respGood([$exp[1]]), $this->req($in[6])); + // test the special case when labels are not used + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([])); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([])); + $this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); + } + + public function testRetrieveCompactHeadlines() { + $in1 = [ + // erroneous input + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"], + // empty results + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], // is_cat is not used in getCompactHeadlines + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"], + // non-empty results + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47], + ]; + $in2 = [ + // time-based contexts, handled separately + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], + ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"], + ]; + Phake::when(Arsse::$db)->articleList->thenReturn(new Result([['id' => 0]])); + Phake::when(Arsse::$db)->articleCount->thenReturn(0); + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); + $c = (new Context)->reverse(true); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), Database::LIST_MINIMAL)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, Database::LIST_MINIMAL)->thenReturn(new Result($this->articles)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 2]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 3]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 4]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 5]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 6]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 7]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 8]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 9]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 10]])); + $out1 = [ + $this->respErr("INCORRECT_USAGE"), + $this->respGood([]), + $this->respGood([]), + $this->respGood([]), + $this->respGood([]), + $this->respGood([]), + $this->respGood([]), + $this->respGood([['id' => 101],['id' => 102]]), + $this->respGood([['id' => 1]]), + $this->respGood([['id' => 2]]), + $this->respGood([['id' => 3]]), + $this->respGood([['id' => 2]]), // the result is 2 rather than 4 because there are no unread, so the unread context is not used + $this->respGood([['id' => 4]]), + $this->respGood([['id' => 5]]), + $this->respGood([['id' => 6]]), + $this->respGood([['id' => 7]]), + $this->respGood([['id' => 8]]), + $this->respGood([['id' => 9]]), + $this->respGood([['id' => 10]]), + ]; + $out2 = [ + $this->respGood([['id' => 1001]]), + $this->respGood([['id' => 1001]]), + $this->respGood([['id' => 1002]]), + $this->respGood([['id' => 1003]]), + ]; + for ($a = 0; $a < sizeof($in1); $a++) { + $this->assertResponse($out1[$a], $this->req($in1[$a]), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]])); + $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); + } + } + + public function testRetrieveFullHeadlines() { + $in1 = [ + // empty results + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ]; + $in2 = [ + // simple context tests + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"], + ]; + $in3 = [ + // time-based context tests + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"], + ]; + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]); + Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]); + Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0)); + Phake::when(Arsse::$db)->articleCount->thenReturn(0); + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); + $c = (new Context)->limit(200)->reverse(true); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), Database::LIST_FULL)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), Database::LIST_FULL)->thenReturn($this->generateHeadlines(2)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(3)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(4)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(5)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(6)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), Database::LIST_FULL)->thenReturn($this->generateHeadlines(7)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), Database::LIST_FULL)->thenReturn($this->generateHeadlines(8)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), Database::LIST_FULL)->thenReturn($this->generateHeadlines(9)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), Database::LIST_FULL)->thenReturn($this->generateHeadlines(10)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), Database::LIST_FULL)->thenReturn($this->generateHeadlines(11)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(12)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), Database::LIST_FULL)->thenReturn($this->generateHeadlines(13)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), Database::LIST_FULL)->thenReturn($this->generateHeadlines(14)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), Database::LIST_FULL)->thenReturn($this->generateHeadlines(15)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), Database::LIST_FULL)->thenReturn($this->generateHeadlines(16)); + $out2 = [ + $this->respErr("INCORRECT_USAGE"), + $this->outputHeadlines(11), + $this->outputHeadlines(1), + $this->outputHeadlines(2), + $this->outputHeadlines(3), + $this->outputHeadlines(2), // the result is 2 rather than 4 because there are no unread, so the unread context is not used + $this->outputHeadlines(4), + $this->outputHeadlines(5), + $this->outputHeadlines(6), + $this->outputHeadlines(7), + $this->outputHeadlines(8), + $this->outputHeadlines(9), + $this->outputHeadlines(10), + $this->outputHeadlines(11), + $this->outputHeadlines(11), + $this->outputHeadlines(12), + $this->outputHeadlines(13), + $this->outputHeadlines(14), + $this->outputHeadlines(15), + $this->outputHeadlines(11), // defaulting sorting is not fully implemented + $this->outputHeadlines(16), + ]; + $out3 = [ + $this->outputHeadlines(1001), + $this->outputHeadlines(1001), + $this->outputHeadlines(1002), + $this->outputHeadlines(1003), + ]; + for ($a = 0; $a < sizeof($in1); $a++) { + $this->assertResponse($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in3); $a++) { + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1001)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1002)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1003)); + $this->assertResponse($out3[$a], $this->req($in3[$a]), "Test $a failed"); + } + } + + public function testRetrieveFullHeadlinesCheckingExtraFields() { + $in = [ + // empty results + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'show_content' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'include_attachments' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'include_header' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true, 'include_header' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true, 'include_header' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'include_header' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'include_header' => true, 'order_by' => "date_reverse"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'skip' => 47, 'include_header' => true], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'skip' => 47, 'include_header' => true, 'order_by' => "date_reverse"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'show_excerpt' => true], + ]; + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]); + Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]); + Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleCount->thenReturn(0); + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); + // sanity check; this makes sure extra fields are not included in default situations + $test = $this->req($in[0]); + $this->assertResponse($this->outputHeadlines(1), $test); + // test 'show_content' + $test = $this->req($in[1]); + $this->assertArrayHasKey("content", $test->payload['content'][0]); + $this->assertArrayHasKey("content", $test->payload['content'][1]); + foreach ($this->generateHeadlines(1) as $key => $row) { + $this->assertSame($row['content'], $test->payload['content'][$key]['content']); + } + // test 'include_attachments' + $test = $this->req($in[2]); + $exp = [ + [ + 'id' => "0", + 'content_url' => "http://example.com/text", + 'content_type' => "text/plain", + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => "2112", + ], + ]; + $this->assertArrayHasKey("attachments", $test->payload['content'][0]); + $this->assertArrayHasKey("attachments", $test->payload['content'][1]); + $this->assertSame([], $test->payload['content'][0]['attachments']); + $this->assertSame($exp, $test->payload['content'][1]['attachments']); + // test 'include_header' + $test = $this->req($in[3]); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -4, 'is_cat' => false, 'first_id' => 1], + $exp->payload['content'], + ]; + $this->assertResponse($exp, $test); + // test 'include_header' with a category + $test = $this->req($in[4]); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -3, 'is_cat' => true, 'first_id' => 1], + $exp->payload['content'], + ]; + $this->assertResponse($exp, $test); + // test 'include_header' with an empty result + $test = $this->req($in[5]); + $exp = $this->respGood([ + ['id' => -1, 'is_cat' => true, 'first_id' => 0], + [], + ]); + $this->assertResponse($exp, $test); + // test 'include_header' with an erroneous result + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + $test = $this->req($in[6]); + $exp = $this->respGood([ + ['id' => 2112, 'is_cat' => false, 'first_id' => 0], + [], + ]); + $this->assertResponse($exp, $test); + // test 'include_header' with ascending order + $test = $this->req($in[7]); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -4, 'is_cat' => false, 'first_id' => 0], + $exp->payload['content'], + ]; + $this->assertResponse($exp, $test); + // test 'include_header' with skip + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867)); + $test = $this->req($in[8]); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => 42, 'is_cat' => false, 'first_id' => 1867], + $exp->payload['content'], + ]; + $this->assertResponse($exp, $test); + // test 'include_header' with skip and ascending order + $test = $this->req($in[9]); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => 42, 'is_cat' => false, 'first_id' => 0], + $exp->payload['content'], + ]; + $this->assertResponse($exp, $test); + // test 'show_excerpt' + $exp1 = "“This & that, you know‽”"; + $exp2 = "Pour vous faire mieux connaitre d’ou\u{300} vient l’erreur de ceux qui bla\u{302}ment la volupte\u{301}, et qui louent en…"; + $test = $this->req($in[10]); + $this->assertArrayHasKey("excerpt", $test->payload['content'][0]); + $this->assertArrayHasKey("excerpt", $test->payload['content'][1]); + $this->assertSame($exp1, $test->payload['content'][0]['excerpt']); + $this->assertSame($exp2, $test->payload['content'][1]['excerpt']); + } + + protected function generateHeadlines(int $id): Result { + return new Result([ + [ + 'id' => $id, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'subscription_title' => "Feed 2112", + 'author' => '', + 'content' => '

“This & that, you know‽”

', + 'guid' => '', + 'published_date' => '2000-01-01 00:00:00', + 'edited_date' => '2000-01-01 00:00:00', + 'modified_date' => '2000-01-01 01:00:00', + 'unread' => 0, + 'starred' => 0, + 'edition' => 101, + 'subscription' => 12, + 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', + 'media_url' => null, + 'media_type' => null, + 'note' => "", + ], + [ + 'id' => 2112, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", + 'author' => 'J. King', + 'content' => $this->richContent, + 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', + 'published_date' => '2000-01-02 00:00:00', + 'edited_date' => '2000-01-02 00:00:02', + 'modified_date' => '2000-01-02 02:00:00', + 'unread' => 1, + 'starred' => 1, + 'edition' => 202, + 'subscription' => 8, + 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', + 'media_url' => "http://example.com/text", + 'media_type' => "text/plain", + 'note' => "Note 2", + ], + ]); + } + + protected function outputHeadlines(int $id): Response { + return $this->respGood([ + [ + 'id' => $id, + 'guid' => null, + 'title' => 'Article title 1', + 'link' => 'http://example.com/1', + 'labels' => [], + 'unread' => false, + 'marked' => false, + 'published' => false, + 'author' => '', + 'updated' => strtotime('2000-01-01 00:00:00'), + 'is_updated' => false, + 'feed_id' => "12", + 'feed_title' => "Feed 2112", + 'score' => 0, + 'note' => null, + 'lang' => "", + 'tags' => [], + 'comments_count' => 0, + 'comments_link' => "", + 'always_display_attachments' => false, + ], + [ + 'id' => 2112, + 'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7", + 'title' => 'Article title 2', + 'link' => 'http://example.com/2', + 'labels' => [ + [-1025, "Logical", "", ""], + [-1027, "Fascinating", "", ""], + ], + 'unread' => true, + 'marked' => true, + 'published' => false, + 'author' => "J. King", + 'updated' => strtotime('2000-01-02 00:00:02'), + 'is_updated' => true, + 'feed_id' => "8", + 'feed_title' => "Feed 11", + 'score' => 0, + 'note' => "Note 2", + 'lang' => "", + 'tags' => ["Boring", "Illogical"], + 'comments_count' => 0, + 'comments_link' => "", + 'always_display_attachments' => false, + ], + ]); + } +} diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php new file mode 100644 index 00000000..1b439b30 --- /dev/null +++ b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php @@ -0,0 +1,52 @@ + */ +class TestTinyTinyIcon extends Test\AbstractTest { + protected $h; + + public function setUp() { + $this->clearData(); + Arsse::$conf = new Conf(); + // create a mock user manager + // create a mock database interface + Arsse::$db = Phake::mock(Database::class); + $this->h = new REST\TinyTinyRSS\Icon(); + } + + public function tearDown() { + $this->clearData(); + } + + public function testRetrieveFavion() { + Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); + Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico"); + Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png"); + Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); + // these requests should succeed + $exp = new Response(301, "", "", ["Location: http://example.com/favicon.ico"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "42.ico"))); + $exp = new Response(301, "", "", ["Location: http://example.net/logo.png"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); + $exp = new Response(301, "", "", ["Location: http://example.org/icon.gif"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); + // these requests should fail + $exp = new Response(404); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "47.ico"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.png"))); + // only GET is allowed + $exp = new Response(405, "", "", ["Allow: GET"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); + } +} diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 30da2840..caf65372 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Test\Database; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; @@ -49,21 +50,22 @@ trait SeriesArticle { 'columns' => [ 'id' => "int", 'url' => "str", + 'title' => "str", ], 'rows' => [ - [1,"http://example.com/1"], - [2,"http://example.com/2"], - [3,"http://example.com/3"], - [4,"http://example.com/4"], - [5,"http://example.com/5"], - [6,"http://example.com/6"], - [7,"http://example.com/7"], - [8,"http://example.com/8"], - [9,"http://example.com/9"], - [10,"http://example.com/10"], - [11,"http://example.com/11"], - [12,"http://example.com/12"], - [13,"http://example.com/13"], + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], ] ], 'arsse_subscriptions' => [ @@ -72,22 +74,23 @@ trait SeriesArticle { 'owner' => "str", 'feed' => "int", 'folder' => "int", + 'title' => "str", ], 'rows' => [ - [1,"john.doe@example.com",1,null], - [2,"john.doe@example.com",2,null], - [3,"john.doe@example.com",3,1], - [4,"john.doe@example.com",4,6], - [5,"john.doe@example.com",10,5], - [6,"jane.doe@example.com",1,null], - [7,"jane.doe@example.com",10,null], - [8,"john.doe@example.org",11,null], - [9,"john.doe@example.org",12,null], - [10,"john.doe@example.org",13,null], - [11,"john.doe@example.net",10,null], - [12,"john.doe@example.net",2,9], - [13,"john.doe@example.net",3,8], - [14,"john.doe@example.net",4,7], + [1, "john.doe@example.com",1, null,"Subscription 1"], + [2, "john.doe@example.com",2, null,null], + [3, "john.doe@example.com",3, 1,"Subscription 3"], + [4, "john.doe@example.com",4, 6,null], + [5, "john.doe@example.com",10, 5,"Subscription 5"], + [6, "jane.doe@example.com",1, null,null], + [7, "jane.doe@example.com",10,null,"Subscription 7"], + [8, "john.doe@example.org",11,null,null], + [9, "john.doe@example.org",12,null,"Subscription 9"], + [10,"john.doe@example.org",13,null,null], + [11,"john.doe@example.net",10,null,"Subscription 11"], + [12,"john.doe@example.net",2, 9,null], + [13,"john.doe@example.net",3, 8,"Subscription 13"], + [14,"john.doe@example.net",4, 7,null], ] ], 'arsse_articles' => [ @@ -193,29 +196,76 @@ trait SeriesArticle { 'article' => "int", 'read' => "bool", 'starred' => "bool", - 'modified' => "datetime" + 'modified' => "datetime", + 'note' => "str", ], 'rows' => [ - [1, 1,1,1,'2000-01-01 00:00:00'], - [5, 19,1,0,'2000-01-01 00:00:00'], - [5, 20,0,1,'2010-01-01 00:00:00'], - [7, 20,1,0,'2010-01-01 00:00:00'], - [8, 102,1,0,'2000-01-02 02:00:00'], - [9, 103,0,1,'2000-01-03 03:00:00'], - [9, 104,1,1,'2000-01-04 04:00:00'], - [10,105,0,0,'2000-01-05 05:00:00'], - [11, 19,0,0,'2017-01-01 00:00:00'], - [11, 20,1,0,'2017-01-01 00:00:00'], - [12, 3,0,1,'2017-01-01 00:00:00'], - [12, 4,1,1,'2017-01-01 00:00:00'], + [1, 1,1,1,'2000-01-01 00:00:00',''], + [5, 19,1,0,'2016-01-01 00:00:00',''], + [5, 20,0,1,'2005-01-01 00:00:00',''], + [7, 20,1,0,'2010-01-01 00:00:00',''], + [8, 102,1,0,'2000-01-02 02:00:00','Note 2'], + [9, 103,0,1,'2000-01-03 03:00:00','Note 3'], + [9, 104,1,1,'2000-01-04 04:00:00','Note 4'], + [10,105,0,0,'2000-01-05 05:00:00',''], + [11, 19,0,0,'2017-01-01 00:00:00','ook'], + [11, 20,1,0,'2017-01-01 00:00:00','eek'], + [12, 3,0,1,'2017-01-01 00:00:00','ack'], + [12, 4,1,1,'2017-01-01 00:00:00','ach'], + [1, 2,0,0,'2010-01-01 00:00:00','Some Note'], ] ], + 'arsse_categories' => [ // author-supplied categories + 'columns' => [ + 'article' => "int", + 'name' => "str", + ], + 'rows' => [ + [19,"Fascinating"], + [19,"Logical"], + [20,"Interesting"], + [20,"Logical"], + ], + ], + 'arsse_labels' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_label_members' => [ + 'columns' => [ + 'label' => "int", + 'article' => "int", + 'subscription' => "int", + 'assigned' => "bool", + 'modified' => "datetime", + ], + 'rows' => [ + [1, 1,1,1,'2000-01-01 00:00:00'], + [2, 1,1,1,'2000-01-01 00:00:00'], + [1,19,5,1,'2000-01-01 00:00:00'], + [2,20,5,1,'2000-01-01 00:00:00'], + [1, 5,3,0,'2000-01-01 00:00:00'], + [2, 5,3,1,'2000-01-01 00:00:00'], + [4, 7,4,0,'2000-01-01 00:00:00'], + [4, 8,4,1,'2015-01-01 00:00:00'], + ], + ], ]; protected $matches = [ [ 'id' => 101, 'url' => 'http://example.com/1', 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", 'author' => '', 'content' => '

Article content 1

', 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', @@ -229,11 +279,13 @@ trait SeriesArticle { 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', 'media_url' => null, 'media_type' => null, + 'note' => "", ], [ 'id' => 102, 'url' => 'http://example.com/2', 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", 'author' => '', 'content' => '

Article content 2

', 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', @@ -247,11 +299,13 @@ trait SeriesArticle { 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', 'media_url' => "http://example.com/text", 'media_type' => "text/plain", + 'note' => "Note 2", ], [ 'id' => 103, 'url' => 'http://example.com/3', 'title' => 'Article title 3', + 'subscription_title' => "Subscription 9", 'author' => '', 'content' => '

Article content 3

', 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', @@ -265,11 +319,13 @@ trait SeriesArticle { 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', 'media_url' => "http://example.com/video", 'media_type' => "video/webm", + 'note' => "Note 3", ], [ 'id' => 104, 'url' => 'http://example.com/4', 'title' => 'Article title 4', + 'subscription_title' => "Subscription 9", 'author' => '', 'content' => '

Article content 4

', 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', @@ -283,11 +339,13 @@ trait SeriesArticle { 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', 'media_url' => "http://example.com/image", 'media_type' => "image/svg+xml", + 'note' => "Note 4", ], [ 'id' => 105, 'url' => 'http://example.com/5', 'title' => 'Article title 5', + 'subscription_title' => "Feed 13", 'author' => '', 'content' => '

Article content 5

', 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', @@ -301,11 +359,32 @@ trait SeriesArticle { 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', 'media_url' => "http://example.com/audio", 'media_type' => "audio/ogg", + 'note' => "", + ], + ]; + protected $fields = [ + Database::LIST_MINIMAL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", + ], + Database::LIST_CONSERVATIVE => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", + "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", + ], + Database::LIST_TYPICAL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", + "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", + "content", "media_url", "media_type", + ], + Database::LIST_FULL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", + "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", + "content", "media_url", "media_type", + "note", ], ]; public function setUpSeries() { - $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified"],]; + $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"],]; $this->user = "john.doe@example.net"; } @@ -321,12 +400,14 @@ trait SeriesArticle { // get all items for user $exp = [1,2,3,4,5,6,7,8,19,20]; $this->compareIds($exp, new Context); + $this->compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); // get items from a folder tree - $exp = [5,6,7,8]; - $this->compareIds($exp, (new Context)->folder(1)); + $this->compareIds([5,6,7,8], (new Context)->folder(1)); // get items from a leaf folder - $exp = [7,8]; - $this->compareIds($exp, (new Context)->folder(6)); + $this->compareIds([7,8], (new Context)->folder(6)); + // get items from a non-leaf folder without descending + $this->compareIds([1,2,3,4], (new Context)->folderShallow(0)); + $this->compareIds([5,6], (new Context)->folderShallow(1)); // get items from a single subscription $exp = [19,20]; $this->compareIds($exp, (new Context)->subscription(5)); @@ -342,13 +423,21 @@ trait SeriesArticle { $this->compareIds([19], (new Context)->subscription(5)->latestEdition(19)); $this->compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); $this->compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); - // get items relative to modification date + // get items relative to article ID + $this->compareIds([1,2,3], (new Context)->latestArticle(3)); + $this->compareIds([19,20], (new Context)->oldestArticle(19)); + // get items relative to (feed) modification date $exp = [2,4,6,8,20]; $this->compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); $this->compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); $exp = [1,3,5,7,19]; $this->compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); $this->compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); + // get items relative to (user) modification date (both marks and labels apply) + $this->compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); + $this->compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); + $this->compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); + $this->compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); // paged results $this->compareIds([1], (new Context)->limit(1)); $this->compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); @@ -359,6 +448,24 @@ trait SeriesArticle { $this->compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); $this->compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); $this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); + // get articles by label ID + $this->compareIds([1,19], (new Context)->label(1)); + $this->compareIds([1,5,20], (new Context)->label(2)); + // get articles by label name + $this->compareIds([1,19], (new Context)->labelName("Interesting")); + $this->compareIds([1,5,20], (new Context)->labelName("Fascinating")); + // get articles with any or no label + $this->compareIds([1,5,8,19,20], (new Context)->labelled(true)); + $this->compareIds([2,3,4,6,7], (new Context)->labelled(false)); + // get a specific article or edition + $this->compareIds([20], (new Context)->article(20)); + $this->compareIds([20], (new Context)->edition(1001)); + // get multiple specific articles or editions + $this->compareIds([1,20], (new Context)->articles([1,20,50])); + $this->compareIds([1,20], (new Context)->editions([1,1001,50])); + // get articles base on whether or not they have notes + $this->compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); + $this->compareIds([2], (new Context)->annotated(true)); } public function testListArticlesOfAMissingFolder() { @@ -374,6 +481,16 @@ trait SeriesArticle { public function testListArticlesCheckingProperties() { $this->user = "john.doe@example.org"; $this->assertResult($this->matches, Arsse::$db->articleList($this->user)); + // check that the different fieldset groups return the expected columns + foreach ($this->fields as $constant => $columns) { + $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $constant)->getRow()); + sort($columns); + sort($test); + $this->assertEquals($columns, $test, "Fields do not match expectation for verbosity $constant"); + } + // check that an unknown fieldset produces an exception + $this->assertException("constantUnknown"); + Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX); } public function testListArticlesWithoutAuthority() { @@ -401,10 +518,10 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][10][2] = 1; $state['arsse_marks']['rows'][10][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,1,0,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,7,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,8,1,0,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; $this->compareExpectations($state); } @@ -427,10 +544,10 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,0,1,$now]; - $state['arsse_marks']['rows'][] = [13,6,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,8,0,1,$now]; + $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; $this->compareExpectations($state); } @@ -459,10 +576,10 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][10][2] = 1; $state['arsse_marks']['rows'][10][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,1,1,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,1,$now]; - $state['arsse_marks']['rows'][] = [14,7,1,1,$now]; - $state['arsse_marks']['rows'][] = [14,8,1,1,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,1,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'']; $this->compareExpectations($state); } @@ -477,10 +594,10 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,0,1,$now]; - $state['arsse_marks']['rows'][] = [13,6,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,8,0,1,$now]; + $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; $this->compareExpectations($state); } @@ -495,10 +612,29 @@ trait SeriesArticle { $state['arsse_marks']['rows'][10][4] = $now; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,1,0,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,7,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,8,1,0,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; + $this->compareExpectations($state); + } + + public function testSetNoteForAllArticles() { + Arsse::$db->articleMark($this->user, ['note'=>"New note"]); + $now = Date::transform(time(), "sql"); + $state = $this->primeExpectations($this->data, $this->checkTables); + $state['arsse_marks']['rows'][8][5] = "New note"; + $state['arsse_marks']['rows'][8][4] = $now; + $state['arsse_marks']['rows'][9][5] = "New note"; + $state['arsse_marks']['rows'][9][4] = $now; + $state['arsse_marks']['rows'][10][5] = "New note"; + $state['arsse_marks']['rows'][10][4] = $now; + $state['arsse_marks']['rows'][11][5] = "New note"; + $state['arsse_marks']['rows'][11][4] = $now; + $state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note']; + $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note']; + $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note']; + $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note']; $this->compareExpectations($state); } @@ -506,10 +642,10 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(7)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); - $state['arsse_marks']['rows'][] = [13,5,1,0,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,7,1,0,$now]; - $state['arsse_marks']['rows'][] = [14,8,1,0,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; $this->compareExpectations($state); } @@ -517,8 +653,8 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(8)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); - $state['arsse_marks']['rows'][] = [13,5,1,0,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,0,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $this->compareExpectations($state); } @@ -531,8 +667,8 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->subscription(13)); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); - $state['arsse_marks']['rows'][] = [13,5,1,0,$now]; - $state['arsse_marks']['rows'][] = [13,6,1,0,$now]; + $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $this->compareExpectations($state); } @@ -556,7 +692,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $this->compareExpectations($state); } @@ -569,7 +705,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $this->compareExpectations($state); } @@ -579,8 +715,7 @@ trait SeriesArticle { } public function testMarkTooManyMultipleArticles() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, 51))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)))); } public function testMarkAMissingArticle() { @@ -603,7 +738,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $this->compareExpectations($state); } @@ -635,7 +770,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $this->compareExpectations($state); } @@ -645,8 +780,7 @@ trait SeriesArticle { } public function testMarkTooManyMultipleEditions() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)))); } public function testMarkAStaleEditionUnread() { @@ -701,15 +835,15 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][8][3] = 1; $state['arsse_marks']['rows'][8][4] = $now; - $state['arsse_marks']['rows'][] = [13,5,0,1,$now]; - $state['arsse_marks']['rows'][] = [13,6,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,8,0,1,$now]; + $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; $this->compareExpectations($state); } - public function testMarkByLastModified() { - Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->modifiedSince('2017-01-01T00:00:00Z')); + public function testMarkByLastMarked() { + Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->markedSince('2017-01-01T00:00:00Z')); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][8][3] = 1; @@ -719,12 +853,12 @@ trait SeriesArticle { $this->compareExpectations($state); } - public function testMarkByNotLastModified() { - Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notModifiedSince('2000-01-01T00:00:00Z')); + public function testMarkByNotLastMarked() { + Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notMarkedSince('2000-01-01T00:00:00Z')); $now = Date::transform(time(), "sql"); $state = $this->primeExpectations($this->data, $this->checkTables); - $state['arsse_marks']['rows'][] = [13,5,0,1,$now]; - $state['arsse_marks']['rows'][] = [14,7,0,1,$now]; + $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; + $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $this->compareExpectations($state); } @@ -734,17 +868,30 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['read'=>false]); } - public function testCountStarredArticles() { - $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.com")); - $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.org")); - $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.net")); - $this->assertSame(0, Arsse::$db->articleStarredCount("jane.doe@example.com")); + public function testCountArticles() { + $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); + $this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1))); + $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true))); + $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_ARTICLES *3)))); } - public function testCountStarredArticlesWithoutAuthority() { + public function testCountArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); - Arsse::$db->articleStarredCount($this->user); + Arsse::$db->articleCount($this->user); + } + + public function testFetchStarredCounts() { + $exp1 = ['total' => 2, 'unread' => 1, 'read' => 1]; + $exp2 = ['total' => 0, 'unread' => 0, 'read' => 0]; + $this->assertSame($exp1, Arsse::$db->articleStarred("john.doe@example.com")); + $this->assertSame($exp2, Arsse::$db->articleStarred("jane.doe@example.com")); + } + + public function testFetchStarredCountsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->articleStarred($this->user); } public function testFetchLatestEdition() { @@ -762,4 +909,44 @@ trait SeriesArticle { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->editionLatest($this->user); } + + public function testListTheLabelsOfAnArticle() { + $this->assertEquals([2,1], Arsse::$db->articleLabelsGet("john.doe@example.com", 1)); + $this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5)); + $this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2)); + $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->articleLabelsGet("john.doe@example.com", 1, true)); + $this->assertEquals(["Fascinating"], Arsse::$db->articleLabelsGet("john.doe@example.com", 5, true)); + $this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2, true)); + } + + public function testListTheLabelsOfAMissingArticle() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->articleLabelsGet($this->user, 101); + } + + public function testListTheLabelsOfAnArticleWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->articleLabelsGet("john.doe@example.com", 1); + } + + public function testListTheCategoriesOfAnArticle() { + $exp = ["Fascinating", "Logical"]; + $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 19)); + $exp = ["Interesting", "Logical"]; + $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 20)); + $exp = []; + $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 4)); + } + + public function testListTheCategoriesOfAMissingArticle() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->articleCategoriesGet($this->user, 101); + } + + public function testListTheCategoriesOfAnArticleWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->articleCategoriesGet($this->user, 19); + } } diff --git a/tests/lib/Database/SeriesCleanup.php b/tests/lib/Database/SeriesCleanup.php index e121d1d6..759b2989 100644 --- a/tests/lib/Database/SeriesCleanup.php +++ b/tests/lib/Database/SeriesCleanup.php @@ -17,6 +17,8 @@ trait SeriesCleanup { $daybefore = gmdate("Y-m-d H:i:s", strtotime("now - 2 days")); $daysago = gmdate("Y-m-d H:i:s", strtotime("now - 7 days")); $weeksago = gmdate("Y-m-d H:i:s", strtotime("now - 21 days")); + $soon = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute")); + $faroff = gmdate("Y-m-d H:i:s", strtotime("now + 1 hour")); $this->data = [ 'arsse_users' => [ 'columns' => [ @@ -29,6 +31,21 @@ trait SeriesCleanup { ["john.doe@example.com", "", "John Doe"], ], ], + 'arsse_sessions' => [ + 'columns' => [ + 'id' => "str", + 'created' => "datetime", + 'expires' => "datetime", + 'user' => "str", + ], + 'rows' => [ + ["a", $nowish, $faroff, "jane.doe@example.com"], // not expired and recently created, thus kept + ["b", $nowish, $soon, "jane.doe@example.com"], // not expired and recently created, thus kept + ["c", $daysago, $soon, "jane.doe@example.com"], // created more than a day ago, thus deleted + ["d", $nowish, $nowish, "jane.doe@example.com"], // recently created but expired, thus deleted + ["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted + ], + ], 'arsse_feeds' => [ 'columns' => [ 'id' => "int", @@ -169,4 +186,15 @@ trait SeriesCleanup { ]); $this->compareExpectations($state); } + + public function testCleanUpExpiredSessions() { + Arsse::$db->sessionCleanup(); + $state = $this->primeExpectations($this->data, [ + 'arsse_sessions' => ["id"] + ]); + foreach ([3,4,5] as $id) { + unset($state['arsse_sessions']['rows'][$id - 1]); + } + $this->compareExpectations($state); + } } diff --git a/tests/lib/Database/SeriesFolder.php b/tests/lib/Database/SeriesFolder.php index b0f2b42a..d2d5b251 100644 --- a/tests/lib/Database/SeriesFolder.php +++ b/tests/lib/Database/SeriesFolder.php @@ -113,16 +113,16 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null], - ['id' => 1, 'name' => "Technology", 'parent' => null], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], ]; - $this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", null, false)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ - ['id' => 4, 'name' => "Politics", 'parent' => null], + ['id' => 4, 'name' => "Politics", 'parent' => null, 'children' => 0], ]; - $this->assertSame($exp, Arsse::$db->folderList("jane.doe@example.com", null, false)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", null, false)); $exp = []; - $this->assertSame($exp, Arsse::$db->folderList("admin@example.net", null, false)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("admin@example.net", null, false)); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderList"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList"); Phake::verify(Arsse::$user)->authorize("admin@example.net", "folderList"); @@ -130,21 +130,21 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null], - ['id' => 6, 'name' => "Politics", 'parent' => 2], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1], - ['id' => 2, 'name' => "Software", 'parent' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], ]; - $this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", null, true)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1], - ['id' => 2, 'name' => "Software", 'parent' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], ]; - $this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; - $this->assertSame($exp, Arsse::$db->folderList("jane.doe@example.com", 4, true)->getAll()); + $this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", 4, true)); Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "folderList"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList"); } diff --git a/tests/lib/Database/SeriesLabel.php b/tests/lib/Database/SeriesLabel.php new file mode 100644 index 00000000..c764b046 --- /dev/null +++ b/tests/lib/Database/SeriesLabel.php @@ -0,0 +1,517 @@ + [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], + ], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + [7, "john.doe@example.net", null, "Technology"], + [8, "john.doe@example.net", 7, "Software"], + [9, "john.doe@example.net", null, "Politics"], + ] + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + ], + 'rows' => [ + [1,"http://example.com/1"], + [2,"http://example.com/2"], + [3,"http://example.com/3"], + [4,"http://example.com/4"], + [5,"http://example.com/5"], + [6,"http://example.com/6"], + [7,"http://example.com/7"], + [8,"http://example.com/8"], + [9,"http://example.com/9"], + [10,"http://example.com/10"], + [11,"http://example.com/11"], + [12,"http://example.com/12"], + [13,"http://example.com/13"], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1,"john.doe@example.com",1,null], + [2,"john.doe@example.com",2,null], + [3,"john.doe@example.com",3,1], + [4,"john.doe@example.com",4,6], + [5,"john.doe@example.com",10,5], + [6,"jane.doe@example.com",1,null], + [7,"jane.doe@example.com",10,null], + [8,"john.doe@example.org",11,null], + [9,"john.doe@example.org",12,null], + [10,"john.doe@example.org",13,null], + [11,"john.doe@example.net",10,null], + [12,"john.doe@example.net",2,9], + [13,"john.doe@example.net",3,8], + [14,"john.doe@example.net",4,7], + ] + ], + 'arsse_articles' => [ + 'columns' => [ + 'id' => "int", + 'feed' => "int", + 'url' => "str", + 'title' => "str", + 'author' => "str", + 'published' => "datetime", + 'edited' => "datetime", + 'content' => "str", + 'guid' => "str", + 'url_title_hash' => "str", + 'url_content_hash' => "str", + 'title_content_hash' => "str", + 'modified' => "datetime", + ], + 'rows' => [ + [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','

Article content 1

','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'], + [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','

Article content 2

','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'], + [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','

Article content 3

','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'], + [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','

Article content 4

','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'], + [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','

Article content 5

','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'], + ] + ], + 'arsse_enclosures' => [ + 'columns' => [ + 'article' => "int", + 'url' => "str", + 'type' => "str", + ], + 'rows' => [ + [102,"http://example.com/text","text/plain"], + [103,"http://example.com/video","video/webm"], + [104,"http://example.com/image","image/svg+xml"], + [105,"http://example.com/audio","audio/ogg"], + + ] + ], + 'arsse_editions' => [ + 'columns' => [ + 'id' => "int", + 'article' => "int", + ], + 'rows' => [ + [1,1], + [2,2], + [3,3], + [4,4], + [5,5], + [6,6], + [7,7], + [8,8], + [9,9], + [10,10], + [11,11], + [12,12], + [13,13], + [14,14], + [15,15], + [16,16], + [17,17], + [18,18], + [19,19], + [20,20], + [101,101], + [102,102], + [103,103], + [104,104], + [105,105], + [202,102], + [203,103], + [204,104], + [205,105], + [305,105], + [1001,20], + ] + ], + 'arsse_marks' => [ + 'columns' => [ + 'subscription' => "int", + 'article' => "int", + 'read' => "bool", + 'starred' => "bool", + 'modified' => "datetime" + ], + 'rows' => [ + [1, 1,1,1,'2000-01-01 00:00:00'], + [5, 19,1,0,'2000-01-01 00:00:00'], + [5, 20,0,1,'2010-01-01 00:00:00'], + [7, 20,1,0,'2010-01-01 00:00:00'], + [8, 102,1,0,'2000-01-02 02:00:00'], + [9, 103,0,1,'2000-01-03 03:00:00'], + [9, 104,1,1,'2000-01-04 04:00:00'], + [10,105,0,0,'2000-01-05 05:00:00'], + [11, 19,0,0,'2017-01-01 00:00:00'], + [11, 20,1,0,'2017-01-01 00:00:00'], + [12, 3,0,1,'2017-01-01 00:00:00'], + [12, 4,1,1,'2017-01-01 00:00:00'], + ] + ], + 'arsse_labels' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_label_members' => [ + 'columns' => [ + 'label' => "int", + 'article' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1, 1,1,1], + [2, 1,1,1], + [1,19,5,1], + [2,20,5,1], + [1, 5,3,0], + [2, 5,3,1], + ], + ], + ]; + + public function setUpSeries() { + $this->checkLabels = ['arsse_labels' => ["id","owner","name"]]; + $this->checkMembers = ['arsse_label_members' => ["label","article","subscription","assigned"]]; + $this->user = "john.doe@example.com"; + } + + public function testAddALabel() { + $user = "john.doe@example.com"; + $labelID = $this->nextID("arsse_labels"); + $this->assertSame($labelID, Arsse::$db->labelAdd($user, ['name' => "Entertaining"])); + Phake::verify(Arsse::$user)->authorize($user, "labelAdd"); + $state = $this->primeExpectations($this->data, $this->checkLabels); + $state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"]; + $this->compareExpectations($state); + } + + public function testAddADuplicateLabel() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Interesting"]); + } + + public function testAddALabelWithAMissingName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->labelAdd("john.doe@example.com", []); + } + + public function testAddALabelWithABlankName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->labelAdd("john.doe@example.com", ['name' => ""]); + } + + public function testAddALabelWithAWhitespaceName() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + Arsse::$db->labelAdd("john.doe@example.com", ['name' => " "]); + } + + public function testAddALabelWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Boring"]); + } + + public function testListLabels() { + $exp = [ + ['id' => 2, 'name' => "Fascinating", 'articles' => 3, 'read' => 1], + ['id' => 1, 'name' => "Interesting", 'articles' => 2, 'read' => 2], + ['id' => 4, 'name' => "Lonely", 'articles' => 0, 'read' => 0], + ]; + $this->assertResult($exp, Arsse::$db->labelList("john.doe@example.com")); + $exp = [ + ['id' => 3, 'name' => "Boring", 'articles' => 0], + ]; + $this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com")); + $exp = []; + $this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com", false)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList"); + } + + public function testListLabelsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelList("john.doe@example.com"); + } + + public function testRemoveALabel() { + $this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", 1)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); + $state = $this->primeExpectations($this->data, $this->checkLabels); + array_shift($state['arsse_labels']['rows']); + $this->compareExpectations($state); + } + + public function testRemoveALabelByName() { + $this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", "Interesting", true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); + $state = $this->primeExpectations($this->data, $this->checkLabels); + array_shift($state['arsse_labels']['rows']); + $this->compareExpectations($state); + } + + public function testRemoveAMissingLabel() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelRemove("john.doe@example.com", 2112); + } + + public function testRemoveAnInvalidLabel() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelRemove("john.doe@example.com", -1); + } + + public function testRemoveAnInvalidLabelByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelRemove("john.doe@example.com", [], true); + } + + public function testRemoveALabelOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelRemove("john.doe@example.com", 3); // label ID 3 belongs to Jane + } + + public function testRemoveALabelWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelRemove("john.doe@example.com", 1); + } + + public function testGetThePropertiesOfALabel() { + $exp = [ + 'id' => 2, + 'name' => "Fascinating", + 'articles' => 3, + 'read' => 1, + ]; + $this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", 2)); + $this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", "Fascinating", true)); + Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "labelPropertiesGet"); + } + + public function testGetThePropertiesOfAMissingLabel() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesGet("john.doe@example.com", 2112); + } + + public function testGetThePropertiesOfAnInvalidLabel() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesGet("john.doe@example.com", -1); + } + + public function testGetThePropertiesOfAnInvalidLabelByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesGet("john.doe@example.com", [], true); + } + + public function testGetThePropertiesOfALabelOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesGet("john.doe@example.com", 3); // label ID 3 belongs to Jane + } + + public function testGetThePropertiesOfALabelWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelPropertiesGet("john.doe@example.com", 1); + } + + public function testMakeNoChangesToALabel() { + $this->assertFalse(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, [])); + } + + public function testRenameALabel() { + $this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"])); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkLabels); + $state['arsse_labels']['rows'][0][2] = "Curious"; + $this->compareExpectations($state); + } + + public function testRenameALabelByName() { + $this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkLabels); + $state['arsse_labels']['rows'][0][2] = "Curious"; + $this->compareExpectations($state); + } + + public function testRenameALabelToTheEmptyString() { + $this->assertException("missing", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => ""])); + } + + public function testRenameALabelToWhitespaceOnly() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => " "])); + } + + public function testRenameALabelToAnInvalidValue() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => []])); + } + + public function testCauseALabelCollision() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]); + } + + public function testSetThePropertiesOfAMissingLabel() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidLabel() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidLabelByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true); + } + + public function testSetThePropertiesOfALabelForTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // label ID 3 belongs to Jane + } + + public function testSetThePropertiesOfALabelWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); + } + + public function testListLabelledArticles() { + $exp = [1,19]; + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 1)); + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Interesting", true)); + $exp = [1,5,20]; + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 2)); + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Fascinating", true)); + $exp = []; + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 4)); + $this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Lonely", true)); + } + + public function testListLabelledArticlesForAMissingLabel() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->labelArticlesGet("john.doe@example.com", 3); + } + + public function testListLabelledArticlesForAnInvalidLabel() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->labelArticlesGet("john.doe@example.com", -1); + } + + public function testListLabelledArticlesWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelArticlesGet("john.doe@example.com", 1); + } + + public function testApplyALabelToArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5])); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][4][3] = 1; + $state['arsse_label_members']['rows'][] = [1,2,1,1]; + $this->compareExpectations($state); + } + + public function testClearALabelFromArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $this->compareExpectations($state); + } + + public function testApplyALabelToArticlesByName() { + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][4][3] = 1; + $state['arsse_label_members']['rows'][] = [1,2,1,1]; + $this->compareExpectations($state); + } + + public function testClearALabelFromArticlesByName() { + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $this->compareExpectations($state); + } + + public function testApplyALabelToArticlesWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5])); + } +} diff --git a/tests/lib/Database/SeriesSession.php b/tests/lib/Database/SeriesSession.php new file mode 100644 index 00000000..2f9c93cd --- /dev/null +++ b/tests/lib/Database/SeriesSession.php @@ -0,0 +1,122 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ], + ], + 'arsse_sessions' => [ + 'columns' => [ + 'id' => "str", + 'user' => "str", + 'created' => "datetime", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "jane.doe@example.com", $past, $faroff], + ["27c6de8da13311e78667001e673b2560", "jane.doe@example.com", $past, $past], // expired + ["ab3b3eb8a13311e78667001e673b2560", "jane.doe@example.com", $old, $future], // too old + ["da772f8fa13c11e78667001e673b2560", "john.doe@example.com", $past, $future], + ], + ], + ]; + } + + public function testResumeAValidSession() { + $exp1 = [ + 'id' => "80fa94c1a11f11e78667001e673b2560", + 'user' => "jane.doe@example.com" + ]; + $exp2 = [ + 'id' => "da772f8fa13c11e78667001e673b2560", + 'user' => "john.doe@example.com" + ]; + $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560")); + $this->assertArraySubset($exp2, Arsse::$db->sessionResume("da772f8fa13c11e78667001e673b2560")); + $now = time(); + // sessions near timeout should be refreshed automatically + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + $state['arsse_sessions']['rows'][3][2] = Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"); + $this->compareExpectations($state); + // session resumption should not check authorization + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560")); + } + + public function testResumeAMissingSession() { + $this->assertException("invalid", "User", "ExceptionSession"); + Arsse::$db->sessionResume("thisSessionDoesNotExist"); + } + + public function testResumeAnExpiredSession() { + $this->assertException("invalid", "User", "ExceptionSession"); + Arsse::$db->sessionResume("27c6de8da13311e78667001e673b2560"); + } + + public function testResumeAStaleSession() { + $this->assertException("invalid", "User", "ExceptionSession"); + Arsse::$db->sessionResume("ab3b3eb8a13311e78667001e673b2560"); + } + + public function testCreateASession() { + $user = "jane.doe@example.com"; + $id = Arsse::$db->sessionCreate($user); + $now = time(); + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + $state['arsse_sessions']['rows'][] = [$id, Date::transform($now, "sql"), Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"), $user]; + $this->compareExpectations($state); + } + + public function testCreateASessionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->sessionCreate("jane.doe@example.com"); + } + + public function testDestroyASession() { + $user = "jane.doe@example.com"; + $id = "80fa94c1a11f11e78667001e673b2560"; + $this->assertTrue(Arsse::$db->sessionDestroy($user, $id)); + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + unset($state['arsse_sessions']['rows'][0]); + $this->compareExpectations($state); + // destroying a session which does not exist is not an error + $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); + } + + public function testDestroyASessionForTheWrongUser() { + $user = "john.doe@example.com"; + $id = "80fa94c1a11f11e78667001e673b2560"; + $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); + } + + public function testDestroyASessionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->sessionDestroy("jane.doe@example.com", "80fa94c1a11f11e78667001e673b2560"); + } +} diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 13cbc82a..78f240e3 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -48,6 +48,7 @@ trait SeriesSubscription { 'username' => "str", 'password' => "str", 'next_fetch' => "datetime", + 'favicon' => "str", ], 'rows' => [] // filled in the series setup ], @@ -108,9 +109,9 @@ trait SeriesSubscription { public function setUpSeries() { $this->data['arsse_feeds']['rows'] = [ - [1,"http://example.com/feed1", "Ook", "", "",strtotime("now")], - [2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour")], - [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour")], + [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''], + [2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'], + [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method Arsse::$db = Phake::partialMock(Database::class, $this->drv); @@ -261,6 +262,21 @@ trait SeriesSubscription { } public function testListSubscriptionsInAFolder() { + $exp = [ + [ + 'url' => "http://example.com/feed2", + 'title' => "Eek", + 'folder' => null, + 'top_folder' => null, + 'unread' => 4, + 'pinned' => 1, + 'order_type' => 2, + ], + ]; + $this->assertResult($exp, Arsse::$db->subscriptionList($this->user, null, false)); + } + + public function testListSubscriptionsWithoutRecursion() { $exp = [ [ 'url' => "http://example.com/feed3", @@ -273,6 +289,7 @@ trait SeriesSubscription { ], ]; $this->assertResult($exp, Arsse::$db->subscriptionList($this->user, 2)); + } public function testListSubscriptionsInAMissingFolder() { @@ -286,6 +303,22 @@ trait SeriesSubscription { Arsse::$db->subscriptionList($this->user); } + public function testCountSubscriptions() { + $this->assertSame(2, Arsse::$db->subscriptionCount($this->user)); + $this->assertSame(1, Arsse::$db->subscriptionCount($this->user, 2)); + } + + public function testCountSubscriptionsInAMissingFolder() { + $this->assertException("idMissing", "Db", "ExceptionInput"); + Arsse::$db->subscriptionCount($this->user, 4); + } + + public function testCountSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionCount($this->user); + } + public function testGetThePropertiesOfAMissingSubscription() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->subscriptionPropertiesGet($this->user, 2112); @@ -321,6 +354,9 @@ trait SeriesSubscription { ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; $this->compareExpectations($state); + // making no changes is a valid result + Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); + $this->compareExpectations($state); } public function testMoveASubscriptionToAMissingFolder() { @@ -371,4 +407,20 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]); } + + public function testRetrieveTheFaviconOfASubscription() { + $exp = "http://example.com/favicon.ico"; + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1)); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4)); + // authorization shouldn't have any bearing on this function + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1)); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4)); + // invalid IDs should simply return an empty string + $this->assertSame('', Arsse::$db->subscriptionFavicon(-2112)); + } } diff --git a/tests/lib/Database/SeriesUser.php b/tests/lib/Database/SeriesUser.php index e59a6482..64780175 100644 --- a/tests/lib/Database/SeriesUser.php +++ b/tests/lib/Database/SeriesUser.php @@ -213,6 +213,9 @@ trait SeriesUser { $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','password','name','rights']]); $state['arsse_users']['rows'][0][2] = "James Kirk"; $this->compareExpectations($state); + // making now changes should make no changes :) + Arsse::$db->userPropertiesSet("admin@example.net", ['lifeform' => "tribble"]); + $this->compareExpectations($state); } public function testSetThePropertiesOfAMissingUser() { diff --git a/tests/lib/Result.php b/tests/lib/Result.php index eeeb6cb3..7381a344 100644 --- a/tests/lib/Result.php +++ b/tests/lib/Result.php @@ -17,21 +17,24 @@ class Result implements \JKingWeb\Arsse\Db\Result { // actual public methods public function getValue() { - $arr = $this->next(); if ($this->valid()) { - $keys = array_keys($arr); - return $arr[array_shift($keys)]; + $keys = array_keys($this->current()); + $out = $this->current()[array_shift($keys)]; + $this->next(); + return $out; } + $this->next(); return null; } public function getRow() { - $arr = $this->next(); - return ($this->valid() ? $arr : null); + $out = ($this->valid() ? $this->current() : null); + $this->next(); + return $out; } public function getAll(): array { - return $this->set; + return iterator_to_array($this, false); } public function changes() { @@ -56,22 +59,22 @@ class Result implements \JKingWeb\Arsse\Db\Result { // PHP iterator methods public function valid() { - return !is_null(key($this->set)); + return $this->pos < sizeof($this->set); } public function next() { - return next($this->set); + $this->pos++; } public function current() { - return current($this->set); + return $this->set[$this->key()]; } public function key() { - return key($this->set); + return array_keys($this->set)[$this->pos]; } public function rewind() { - reset($this->set); + $this->pos = 0; } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 44ec358e..0e06627e 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -18,6 +18,14 @@ + + + + 1500 + 1000 + + + @@ -47,6 +55,8 @@ Db/TestTransaction.php + Db/TestResultAggregate.php + Db/TestResultEmpty.php Db/SQLite3/TestDbResultSQLite3.php Db/SQLite3/TestDbStatementSQLite3.php Db/SQLite3/TestDbDriverCreationSQLite3.php @@ -57,15 +67,23 @@ Db/SQLite3/Database/TestDatabaseMiscellanySQLite3.php Db/SQLite3/Database/TestDatabaseMetaSQLite3.php Db/SQLite3/Database/TestDatabaseUserSQLite3.php + Db/SQLite3/Database/TestDatabaseSessionSQLite3.php Db/SQLite3/Database/TestDatabaseFolderSQLite3.php Db/SQLite3/Database/TestDatabaseFeedSQLite3.php Db/SQLite3/Database/TestDatabaseSubscriptionSQLite3.php Db/SQLite3/Database/TestDatabaseArticleSQLite3.php + Db/SQLite3/Database/TestDatabaseLabelSQLite3.php Db/SQLite3/Database/TestDatabaseCleanupSQLite3.php - - REST/NextCloudNews/TestNCNVersionDiscovery.php - REST/NextCloudNews/TestNCNV1_2.php + + + REST/NextCloudNews/TestNCNVersionDiscovery.php + REST/NextCloudNews/TestNCNV1_2.php + + + REST/TinyTinyRSS/TestTinyTinyAPI.php + REST/TinyTinyRSS/TestTinyTinyIcon.php + Service/TestService.php diff --git a/www/tt-rss/images/README b/www/tt-rss/images/README new file mode 100644 index 00000000..86d8d68b --- /dev/null +++ b/www/tt-rss/images/README @@ -0,0 +1,21 @@ +Silk icon set v1.3 +Copyright 2006, Mark James +http://www.famfamfam.com/lab/icons/silk/ + +Used under license: +http://creativecommons.org/licenses/by/2.5/ + +A minimal subset of the Silk icon set used by Tiny Tiny RSS is included here +to provide consistent results with certain API functions. + +Note that TT-RSS renames some of the icons, and we use the modified names, +again for consistency. Below is a table listing the source file names: + +Modified Original +----------- -------------- +archive.png box.png +feed.png feed.png +folder.png folder.png +fresh.png cup.png +label.png tag_yellow.png +time.png time.png diff --git a/www/tt-rss/images/archive.png b/www/tt-rss/images/archive.png new file mode 100644 index 00000000..8443c23e Binary files /dev/null and b/www/tt-rss/images/archive.png differ diff --git a/www/tt-rss/images/feed.png b/www/tt-rss/images/feed.png new file mode 100644 index 00000000..315c4f4f Binary files /dev/null and b/www/tt-rss/images/feed.png differ diff --git a/www/tt-rss/images/folder.png b/www/tt-rss/images/folder.png new file mode 100644 index 00000000..784e8fa4 Binary files /dev/null and b/www/tt-rss/images/folder.png differ diff --git a/www/tt-rss/images/fresh.png b/www/tt-rss/images/fresh.png new file mode 100644 index 00000000..b7bfcd15 Binary files /dev/null and b/www/tt-rss/images/fresh.png differ diff --git a/www/tt-rss/images/label.png b/www/tt-rss/images/label.png new file mode 100644 index 00000000..83d12924 Binary files /dev/null and b/www/tt-rss/images/label.png differ diff --git a/www/tt-rss/images/time.png b/www/tt-rss/images/time.png new file mode 100644 index 00000000..911da3f1 Binary files /dev/null and b/www/tt-rss/images/time.png differ