From 474d32e54f74f828f97f2680e29abad437fc3a7a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 16 Sep 2017 19:57:33 -0400 Subject: [PATCH 01/66] Preliminary implementation of TTRSS sessions (needs tests; may not work) --- composer.json | 4 +- composer.lock | 240 +++++++++++++++++++++++++++++++--- lib/AbstractException.php | 1 + lib/Conf.php | 8 +- lib/Database.php | 56 ++++++++ lib/Service.php | 5 +- lib/User/ExceptionSession.php | 6 + locale/en.php | 1 + sql/SQLite3/1.sql | 29 ++++ 9 files changed, 327 insertions(+), 23 deletions(-) create mode 100644 lib/User/ExceptionSession.php create mode 100644 sql/SQLite3/1.sql diff --git a/composer.json b/composer.json index 9d98159..552c15d 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,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", diff --git a/composer.lock b/composer.lock index ca0b748..844cdb5 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": "d00fd63e825db5ce16878c1639f362f3", "packages": [ { "name": "docopt/docopt", @@ -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.6", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/34a7699e6f31b1ef4035ee36444407cecf9f56aa", + "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa", + "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-06-05T06:31:10+00:00" + }, { "name": "zendframework/zendxml", "version": "1.0.2", @@ -310,6 +447,68 @@ ], "time": "2012-12-19T10:50:58+00:00" }, + { + "name": "composer/semver", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/c7cb9a2095a074d131b65a8a0cd294479d785573", + "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5 || ^5.0.5", + "phpunit/phpunit-mock-objects": "2.3.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "time": "2016-08-30T16:08:34+00:00" + }, { "name": "container-interop/container-interop", "version": "1.2.0", @@ -561,19 +760,20 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.2.6", + "version": "v2.2.7", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "c1cc52c242f17c4d52d9601159631da488fac7a4" + "reference": "b6202ccad4c00778887e7e8282d52f854802b59a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/c1cc52c242f17c4d52d9601159631da488fac7a4", - "reference": "c1cc52c242f17c4d52d9601159631da488fac7a4", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b6202ccad4c00778887e7e8282d52f854802b59a", + "reference": "b6202ccad4c00778887e7e8282d52f854802b59a", "shasum": "" }, "require": { + "composer/semver": "^1.4", "doctrine/annotations": "^1.2", "ext-json": "*", "ext-tokenizer": "*", @@ -641,7 +841,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2017-08-22T14:08:16+00:00" + "time": "2017-09-11T14:27:07+00:00" }, { "name": "gecko-packages/gecko-php-unit", @@ -1599,16 +1799,16 @@ }, { "name": "phake/phake", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/mlively/Phake.git", - "reference": "c242d6a8376bd3280d903d95725d3e1e2f9efadc" + "reference": "949340efc3cd99b401a0dd1a5ffeac690a3c3967" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mlively/Phake/zipball/c242d6a8376bd3280d903d95725d3e1e2f9efadc", - "reference": "c242d6a8376bd3280d903d95725d3e1e2f9efadc", + "url": "https://api.github.com/repos/mlively/Phake/zipball/949340efc3cd99b401a0dd1a5ffeac690a3c3967", + "reference": "949340efc3cd99b401a0dd1a5ffeac690a3c3967", "shasum": "" }, "require": { @@ -1653,7 +1853,7 @@ "mock", "testing" ], - "time": "2017-07-04T20:09:48+00:00" + "time": "2017-09-06T12:09:44+00:00" }, { "name": "phar-io/manifest", @@ -2226,22 +2426,22 @@ }, { "name": "phpspec/prophecy", - "version": "v1.7.0", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "93d39f1f7f9326d746203c7c056f300f7f126073" + "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/93d39f1f7f9326d746203c7c056f300f7f126073", - "reference": "93d39f1f7f9326d746203c7c056f300f7f126073", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", + "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", "sebastian/comparator": "^1.1|^2.0", "sebastian/recursion-context": "^1.0|^2.0|^3.0" }, @@ -2252,7 +2452,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "dev-master": "1.7.x-dev" } }, "autoload": { @@ -2285,7 +2485,7 @@ "spy", "stub" ], - "time": "2017-03-02T20:05:34+00:00" + "time": "2017-09-04T11:05:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3764,7 +3964,7 @@ }, { "name": "symfony/options-resolver", - "version": "v3.3.8", + "version": "v3.3.9", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", @@ -4340,7 +4540,7 @@ }, { "name": "symfony/yaml", - "version": "v3.3.8", + "version": "v3.3.9", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 034254c..1ac5a1d 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -56,6 +56,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 a995a94..7ed80d6 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -28,10 +28,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 4983da3..ddce4f1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -3,6 +3,7 @@ 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; @@ -223,6 +224,61 @@ 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 * 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); + } + // otherwise populate the session user when appropriate + if (Arsse::$user) { + Arsse::$user->id = $out['user']; + } + // 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 { + return $this->db->query("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP")->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__)) { diff --git a/lib/Service.php b/lib/Service.php index baffb32..f1ce235 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -84,7 +84,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 0000000..0f93103 --- /dev/null +++ b/lib/User/ExceptionSession.php @@ -0,0 +1,6 @@ + '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 0000000..ae97f07 --- /dev/null +++ b/sql/SQLite3/1.sql @@ -0,0 +1,29 @@ +-- Sessions for Tiny Tiny RSS (and possibly others) +create table arsse_sessions ( + id text primary key, -- UUID of session + created datetime not null default CURRENT_TIMESTAMP, -- Session start timestamp + expires datetime 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 + foreground text, -- foreground (text) colour in hexdecimal RGB + background text, -- background colour in hexadecimal RGB + 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 + primary key(label,article) +) without rowid; + +-- set version marker +pragma user_version = 2; +insert into arsse_meta(key,value) values('schema_version','2'); \ No newline at end of file From 10694479254b3d5322b06e449a65aa5f0c9e165e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Sep 2017 10:09:36 -0400 Subject: [PATCH 02/66] Make session cleanup more sophisticated --- lib/Database.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ddce4f1..e2b72a8 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -248,8 +248,8 @@ class Database { } public function sessionResume(string $id): array { - $maxage = Date::sub(Arsse::$conf->userSessionLifetime); - $out = $this->db->prepare("SELECT * from arsse_sessions where id is ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxage)->getRow(); + $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); + $out = $this->db->prepare("SELECT * 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); @@ -267,7 +267,8 @@ class Database { } public function sessionCleanup(): int { - return $this->db->query("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP")->changes(); + $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 { From b7ac63b9def754fa2387b9c5f537effb2e03000c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Sep 2017 11:22:32 -0400 Subject: [PATCH 03/66] Resuming a session from the database should have no side effects --- lib/Database.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index e2b72a8..e680a80 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -249,15 +249,11 @@ class Database { public function sessionResume(string $id): array { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); - $out = $this->db->prepare("SELECT * from arsse_sessions where id is ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); + $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); } - // otherwise populate the session user when appropriate - if (Arsse::$user) { - Arsse::$user->id = $out['user']; - } // 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); From 91432d4e1657f6480501db81e0a467e3bcc01087 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Sep 2017 12:45:07 -0400 Subject: [PATCH 04/66] Tests for sessions --- lib/Database.php | 4 +- sql/SQLite3/1.sql | 4 +- .../Database/TestDatabaseSessionSQLite3.php | 10 ++ tests/lib/Database/SeriesCleanup.php | 29 +++++ tests/lib/Database/SeriesSession.php | 119 ++++++++++++++++++ tests/phpunit.xml | 1 + 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php create mode 100644 tests/lib/Database/SeriesSession.php diff --git a/lib/Database.php b/lib/Database.php index e680a80..a16979f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -9,7 +9,7 @@ use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; class Database { - const SCHEMA_VERSION = 1; + const SCHEMA_VERSION = 2; /** @var Db\Driver */ public $db; @@ -267,7 +267,7 @@ class Database { return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes(); } - protected function sessionExpiringSoon(DateTimeInterface $expiry): bool { + 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 --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index ae97f07..8a50ed1 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -3,7 +3,7 @@ create table arsse_sessions ( id text primary key, -- UUID of session created datetime not null default CURRENT_TIMESTAMP, -- Session start timestamp expires datetime 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 + 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 @@ -26,4 +26,4 @@ create table arsse_label_members ( -- set version marker pragma user_version = 2; -insert into arsse_meta(key,value) values('schema_version','2'); \ No newline at end of file +update arsse_meta set value = '2' where key is 'schema_version'; \ No newline at end of file diff --git a/tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php b/tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php new file mode 100644 index 0000000..be10b88 --- /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/lib/Database/SeriesCleanup.php b/tests/lib/Database/SeriesCleanup.php index b85c39b..9d60c8b 100644 --- a/tests/lib/Database/SeriesCleanup.php +++ b/tests/lib/Database/SeriesCleanup.php @@ -13,6 +13,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' => [ @@ -25,6 +27,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", @@ -165,4 +182,16 @@ 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/SeriesSession.php b/tests/lib/Database/SeriesSession.php new file mode 100644 index 0000000..95d4b1b --- /dev/null +++ b/tests/lib/Database/SeriesSession.php @@ -0,0 +1,119 @@ +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/phpunit.xml b/tests/phpunit.xml index 2a2b443..bf4e2e4 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -56,6 +56,7 @@ 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 From 3a5d346b9cbb07cf9b0da8e6174db5bdce47d578 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Sep 2017 23:32:21 -0400 Subject: [PATCH 05/66] Preliminary TTRSS handler --- lib/REST.php | 6 +- lib/REST/TinyTinyRSS/API.php | 128 +++++++++++++++++++++++++++++ lib/REST/TinyTinyRSS/Exception.php | 17 ++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 lib/REST/TinyTinyRSS/API.php create mode 100644 lib/REST/TinyTinyRSS/Exception.php diff --git a/lib/REST.php b/lib/REST.php index 68f5e0b..28b9985 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -16,11 +16,15 @@ 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, + ], // 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 // Fever https://feedafever.com/api // NewsBlur http://www.newsblur.com/api // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php new file mode 100644 index 0000000..a6cf363 --- /dev/null +++ b/lib/REST/TinyTinyRSS/API.php @@ -0,0 +1,128 @@ + ["login"], + ]; + + public function __construct() { + } + + public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + if ($req->method != "POST") { + // only POST requests are allowed + return new Response(405, "", "", ["Allow: POST"]); + } + if ($req->body) { + // only JSON entities are allowed + if (!preg_match("<^application/json\b|^$>", $req->type)) { + return new Response(415, "", "", ['Accept: application/json']); + } + $data = @json_decode($req->body, true); + if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { + // non-JSON input indicates an error + return new Response(400); + } + // layer input over defaults + $data = array_merge([ + 'seq' => 0, + 'op' => "", + 'sid' => null, + ], $data); + try { + if (!in_array($data['op'], self::OVERRIDE['auth'])) { + // unless otherwise specified, a session identifier is required + $this->resumeSession($data['sid']); + } + $method = "op".ucfirst($data['op']); + if (!method_exists($this, $method)) { + // because method names are supposed to be case insensitive, we need to try a bit harder to match + $method = strtolower($method); + $map = get_class_methods($this); + $map = array_combine(array_map("strtolower", $map), $map); + if(!array_key_exists($method, $map)) { + // if the method really doesn't exist, throw an exception + throw new Exception("UNKNWON_METHOD", ['method' => $data['op']]); + } + // otherwise retrieve the correct camelCase and continue + $method = $map[$method]; + } + 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(400); + } + } + + protected function resumeSession($id): bool { + try { + // verify the supplied session is valid + $s = Arsse::$db->sessionResume((string) $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): aray { + return ['level' => self::LEVEL]; + } + + public function opGetVersion(array $data): array { + return [ + 'version' => self::VERSION, + 'arsse_version' => \JKingWeb\Arsse\VERSION, + ]; + } + + public function opLogin(array $data): aray { + if (isset($data['user']) && isset($data['password']) && Arsse::$user->auth($data['user'], $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]; + } +} \ No newline at end of file diff --git a/lib/REST/TinyTinyRSS/Exception.php b/lib/REST/TinyTinyRSS/Exception.php new file mode 100644 index 0000000..6674fda --- /dev/null +++ b/lib/REST/TinyTinyRSS/Exception.php @@ -0,0 +1,17 @@ +data = $data; + parent::__construct($msg, 0, $e); + } + + public function getData(): array { + $err = ['error' => $this->getMesssage()]; + return array_merge($err, $this->data, $err); + } +} \ No newline at end of file From 6e19517593c2a3129a23bbc5b408c06a21288e0d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Sep 2017 08:15:39 -0400 Subject: [PATCH 06/66] Return correct TTRSS error bodies even for HTTP-level errors TTRSS' error message for these cases (NOT_LOGGED_IN) is not especially helpful, but that's what it returns, so that's what we should return, albeit with correct HTTP status codes. --- lib/REST/TinyTinyRSS/API.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a6cf363..3e27186 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -14,6 +14,11 @@ use JKingWeb\Arsse\REST\Response; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; + const FATAL_ERR = [ + 'seq' => null, + 'status' => 1, + 'content' => ['error' => "NOT_LOGGED_IN"], + ]; const OVVERIDE = [ 'auth' => ["login"], ]; @@ -24,17 +29,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { if ($req->method != "POST") { // only POST requests are allowed - return new Response(405, "", "", ["Allow: POST"]); + return new Response(405, self::FATAL_ERR, "application/json", ["Allow: POST"]); } if ($req->body) { // only JSON entities are allowed if (!preg_match("<^application/json\b|^$>", $req->type)) { - return new Response(415, "", "", ['Accept: application/json']); + return new Response(415, self::FATAL_ERR, "application/json", ['Accept: application/json']); } $data = @json_decode($req->body, true); if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { // non-JSON input indicates an error - return new Response(400); + return new Response(400, self::FATAL_ERR); } // layer input over defaults $data = array_merge([ @@ -76,7 +81,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } else { // absence of a request body indicates an error - return new Response(400); + return new Response(400, self::FATAL_ERR); } } From 8487a56a6055553e874adf5182670cd08af6d286 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Sep 2017 10:08:37 -0400 Subject: [PATCH 07/66] Tests for TTRSS session handling and version/level reporting - Fixes #73 - Fixes #74 - Fixes #75 - Fixes #76 - Fixes #77 --- lib/REST/TinyTinyRSS/API.php | 6 +- lib/REST/TinyTinyRSS/Exception.php | 2 +- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 251 +++++++++++++++++++++ tests/phpunit.xml | 3 +- 4 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 tests/REST/TinyTinyRSS/TestTinyTinyAPI.php diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 3e27186..f0648de 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -19,7 +19,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'status' => 1, 'content' => ['error' => "NOT_LOGGED_IN"], ]; - const OVVERIDE = [ + const OVERRIDE = [ 'auth' => ["login"], ]; @@ -98,7 +98,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } - public function opGetApiLevel(array $data): aray { + public function opGetApiLevel(array $data): array { return ['level' => self::LEVEL]; } @@ -109,7 +109,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { ]; } - public function opLogin(array $data): aray { + public function opLogin(array $data): array { if (isset($data['user']) && isset($data['password']) && Arsse::$user->auth($data['user'], $data['password'])) { $id = Arsse::$db->sessionCreate($data['user']); return [ diff --git a/lib/REST/TinyTinyRSS/Exception.php b/lib/REST/TinyTinyRSS/Exception.php index 6674fda..bc4bb8b 100644 --- a/lib/REST/TinyTinyRSS/Exception.php +++ b/lib/REST/TinyTinyRSS/Exception.php @@ -11,7 +11,7 @@ class Exception extends \Exception { } public function getData(): array { - $err = ['error' => $this->getMesssage()]; + $err = ['error' => $this->getMessage()]; return array_merge($err, $this->data, $err); } } \ No newline at end of file diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php new file mode 100644 index 0000000..f18adea --- /dev/null +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -0,0 +1,251 @@ + */ +class TestTinyTinyAPI extends Test\AbstractTest { + protected $h; + protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler + 'db' => [ + [ + 'id' => 2112, + 'url' => 'http://example.com/news.atom', + 'favicon' => 'http://example.com/favicon.png', + 'source' => 'http://example.com/', + 'folder' => null, + 'top_folder' => null, + 'pinned' => 0, + 'err_count' => 0, + 'err_msg' => '', + 'order_type' => 0, + 'added' => '2017-05-20 13:35:54', + 'title' => 'First example feed', + 'unread' => 50048, + ], + [ + 'id' => 42, + 'url' => 'http://example.org/news.atom', + 'favicon' => 'http://example.org/favicon.png', + 'source' => 'http://example.org/', + 'folder' => 12, + 'top_folder' => 8, + 'pinned' => 1, + 'err_count' => 0, + 'err_msg' => '', + 'order_type' => 2, + 'added' => '2017-05-20 13:35:54', + 'title' => 'Second example feed', + 'unread' => 23, + ], + ], + ]; + protected $articles = [ + 'db' => [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', + '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, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + '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", + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', + 'published_date' => '2000-01-03 00:00:00', + 'edited_date' => '2000-01-03 00:00:03', + 'modified_date' => '2000-01-03 03:00:00', + 'unread' => 1, + 'starred' => 1, + 'edition' => 203, + 'subscription' => 9, + 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', + 'media_url' => "http://example.com/video", + 'media_type' => "video/webm", + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', + 'published_date' => '2000-01-04 00:00:00', + 'edited_date' => '2000-01-04 00:00:04', + 'modified_date' => '2000-01-04 04:00:00', + 'unread' => 0, + 'starred' => 1, + 'edition' => 204, + 'subscription' => 9, + 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', + 'media_url' => "http://example.com/image", + 'media_type' => "image/svg+xml", + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', + 'published_date' => '2000-01-05 00:00:00', + 'edited_date' => '2000-01-05 00:00:05', + 'modified_date' => '2000-01-05 05:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 305, + 'subscription' => 10, + 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', + 'media_url' => "http://example.com/audio", + 'media_type' => "audio/ogg", + ], + ] + ]; + + protected function respGood(array $content, $seq = 0): Response { + return new Response(200, [ + 'seq' => $seq, + 'status' => 0, + 'content' => $content, + ]); + } + + protected function respErr(string $msg, array $content = [], $seq = 0): Response { + $err = ['error' => $msg]; + return new Response(200, [ + 'seq' => $seq, + 'status' => 1, + 'content' => array_merge($err, $content, $err), + ]); + } + + 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 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + // test a failed log-in + $data['password'] = "superman"; + $exp = $this->respErr("LOGIN_ERROR"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + // logging in should never try to resume a session + Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); + } + + public function testLogOut() { + Phake::when(Arsse::$db)->sessionDestroy->thenReturn(true); + $data = [ + 'op' => "logout", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => "OK"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); + } + + public function testValidateASession() { + $data = [ + 'op' => "isLoggedIn", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => true]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $data['sid'] = "SolarFederation"; + $exp = $this->respErr("NOT_LOGGED_IN"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + } + + public function testRetrieveServerVersion() { + $data = [ + 'op' => "getVersion", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood([ + 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, + 'arsse_version' => \JKingWeb\Arsse\VERSION, + ]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + } + + public function testRetrieveProtocolLevel() { + $data = [ + 'op' => "getApiLevel", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + } +} \ No newline at end of file diff --git a/tests/phpunit.xml b/tests/phpunit.xml index bf4e2e4..98eba09 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -63,9 +63,10 @@ Db/SQLite3/Database/TestDatabaseArticleSQLite3.php Db/SQLite3/Database/TestDatabaseCleanupSQLite3.php - + REST/NextCloudNews/TestNCNVersionDiscovery.php REST/NextCloudNews/TestNCNV1_2.php + REST/TinyTinyRSS/TestTinyTinyAPI.php Service/TestService.php From 1af8b733b5ab0f73717180cd4336d09ce067478b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Sep 2017 22:45:54 -0400 Subject: [PATCH 08/66] Implement adding TTRSS categories; fixe #99 --- lib/REST/TinyTinyRSS/API.php | 29 +++++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 49 +++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index f0648de..e34081d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -130,4 +130,33 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // session validity is already checked by the dispatcher, so we need only return true return ['status' => true]; } + + public function opAddCategory(array $data) { + $in = [ + 'name' => isset($data['caption']) ? $data['caption'] : "", + 'parent' => isset($data['parent_id']) ? $data['parent_id'] : null, + ]; + if (!$in['parent']) { + $in['parent'] = null; + } + try { + return Arsse::$db->folderAdd(Arsse::$user->id, $in); + } catch (ExceptionInput $e) { + switch ($e->getCode()) { + // folder already exists + case 10236: + // retrieve the ID of the existing folder; duplicating a category 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 (int) $folder['id']; + } + } + // parent folder does not exist; this returns false as an ID + case 10235: return false; + // other errors related to input + default: throw new Exception("INCORRECT_USAGE"); + } + } + } } \ No newline at end of file diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index f18adea..ebb42b8 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -143,7 +143,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ] ]; - protected function respGood(array $content, $seq = 0): Response { + protected function respGood($content = null, $seq = 0): Response { return new Response(200, [ 'seq' => $seq, 'status' => 0, @@ -151,7 +151,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ]); } - protected function respErr(string $msg, array $content = [], $seq = 0): Response { + protected function respErr(string $msg, $content = [], $seq = 0): Response { $err = ['error' => $msg]; return new Response(200, [ 'seq' => $seq, @@ -248,4 +248,49 @@ class TestTinyTinyAPI extends Test\AbstractTest { $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); } + + public function testAddACategory() { + $in = [ + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 1], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx"], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => ""], + ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => " "], + ]; + $db = [ + ['name' => "Software", 'parent' => null], + ['name' => "Hardware", 'parent' => 1], + ]; + $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, [])->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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $exp = $this->respGood(3); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // attempt to add the two folders again + $exp = $this->respGood(2); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $exp = $this->respGood(3); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); + Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); + // add some invalid folders + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + } } \ No newline at end of file From 5488b994f7b3bb424383fb81b3d19a4e74c35e17 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Sep 2017 10:16:24 -0400 Subject: [PATCH 09/66] Merged master; CS fixes --- lib/Conf.php | 2 +- lib/Database.php | 109 ++++++----- lib/Db/SQLite3/Driver.php | 2 +- lib/Misc/Context.php | 2 +- lib/Misc/ValueInfo.php | 63 ++++--- lib/REST/AbstractHandler.php | 37 ++-- lib/REST/NextCloudNews/V1_2.php | 55 +++--- lib/REST/TinyTinyRSS/API.php | 8 +- lib/REST/TinyTinyRSS/Exception.php | 2 +- tests/Misc/TestContext.php | 4 +- tests/Misc/TestValueInfo.php | 204 +++++++++++++++++++++ tests/REST/NextCloudNews/TestNCNV1_2.php | 97 +++++++--- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 6 +- tests/lib/AbstractTest.php | 4 +- tests/lib/Database/SeriesCleanup.php | 1 - tests/lib/Database/SeriesFeed.php | 5 + tests/lib/Database/SeriesFolder.php | 21 ++- tests/lib/Database/SeriesSession.php | 1 - tests/lib/Database/SeriesSubscription.php | 17 +- tests/lib/Misc/StrClass.php | 15 ++ tests/phpunit.xml | 7 +- 21 files changed, 507 insertions(+), 155 deletions(-) create mode 100644 tests/Misc/TestValueInfo.php create mode 100644 tests/lib/Misc/StrClass.php diff --git a/lib/Conf.php b/lib/Conf.php index 7ed80d6..c292a77 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -31,7 +31,7 @@ class Conf { /** @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); + /** @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"; diff --git a/lib/Database.php b/lib/Database.php index d54d986..bf6ce12 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -285,23 +285,19 @@ class Database { // normalize folder's parent, if there is one $parent = array_key_exists("parent", $data) ? $this->folderValidateId($user, $data['parent'])['id'] : null; // validate the folder name and parent (if specified); this also checks for duplicates - $name = array_key_exists("name", $data) ? $data['name'] : ""; + $name = array_key_exists("name", $data) ? $data['name'] : ""; $this->folderValidateName($name, true, $parent); // actually perform the insert return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId(); } - public function folderList(string $user, int $parent = null, bool $recursive = true): Db\Result { + public function folderList(string $user, $parent = null, bool $recursive = 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]); } // check to make sure the parent exists, if one is specified - if (!is_null($parent)) { - if (!$this->db->prepare("SELECT count(*) from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $parent)->getValue()) { - throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "parent", 'id' => $parent]); - } - } + $parent = $this->folderValidateId($user, $parent)['id']; // if we're not returning a recursive list we can use a simpler query if (!$recursive) { return $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and parent is ?", "str", "int")->run($user, $parent); @@ -313,10 +309,13 @@ class Database { } } - public function folderRemove(string $user, int $id): bool { + public function folderRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { 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"]); + } $changes = $this->db->prepare("DELETE FROM arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); if (!$changes) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); @@ -324,10 +323,13 @@ class Database { return true; } - public function folderPropertiesGet(string $user, int $id): array { + public function folderPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { 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"]); + } $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) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); @@ -335,7 +337,7 @@ class Database { return $props; } - public function folderPropertiesSet(string $user, int $id, array $data): bool { + public function folderPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } @@ -347,14 +349,18 @@ class Database { // if a new name and parent are specified, validate both together $this->folderValidateName($data['name']); $in['name'] = $data['name']; - $in['parent'] = $this->folderValidateMove($user, $id, $data['parent'], $data['name']); + $in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent'], $data['name']); } elseif ($name) { + // if we're trying to rename the root folder, this simply fails + if (!$id) { + return false; + } // if a new name is specified, validate it $this->folderValidateName($data['name'], true, $in['parent']); $in['name'] = $data['name']; } elseif ($parent) { // if a new parent is specified, validate it - $in['parent'] = $this->folderValidateMove($user, $id, $data['parent']); + $in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent']); } else { // if neither was specified, do nothing return false; @@ -368,14 +374,13 @@ class Database { } protected function folderValidateId(string $user, $id = null, bool $subject = false): array { - $idInfo = ValueInfo::int($id); - if ($idInfo & (ValueInfo::NULL | ValueInfo::ZERO)) { - // if a null or zero ID is specified this is a no-op - return ['id' => null, 'name' => null, 'parent' => null]; + // if the specified ID is not a non-negative integer (or null), this will always fail + if (!ValueInfo::id($id, true)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "folder", 'type' => "int >= 0"]); } - // if a negative integer or non-integer is specified this will always fail - if (!($idInfo & ValueInfo::VALID) || (($idInfo & ValueInfo::NEG))) { - throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "folder", 'id' => $id]); + // if a null or zero ID is specified this is a no-op + if (!$id) { + return ['id' => null, 'name' => null, 'parent' => null]; } // check whether the folder exists and is owned by the user $f = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow(); @@ -406,7 +411,9 @@ class Database { if ($id==$parent) { throw new Db\ExceptionInput("circularDependence", $errData); } - // make sure both that the prospective parent exists, and that the it is not one of its children (a circular dependence) + // make sure both that the prospective parent exists, and that the it is not one of its children (a circular dependence); + // also make sure that a folder with the same prospective name and parent does not already exist: if the parent is null, + // SQL will happily accept duplicates (null is not unique), so we must do this check ourselves $p = $this->db->prepare( "WITH RECURSIVE target as (select ? as user, ? as source, ? as dest, ? as rename), @@ -416,7 +423,7 @@ class Database { ((select dest from target) is null or exists(select id from arsse_folders join target on owner is user and id is dest)) as extant, not exists(select id from folders where id is (select dest from target)) as valid, not exists(select id from arsse_folders join target on parent is dest and name is coalesce((select rename from target),(select name from arsse_folders join target on id is source))) as available - ", "str", "int", "int","str" + ", "str", "int", "int", "str" )->run($user, $id, $parent, $name)->getRow(); if (!$p['extant']) { // if the parent doesn't exist or doesn't below to the user, throw an exception @@ -425,6 +432,7 @@ class Database { // if using the desired parent would create a circular dependence, throw a different exception throw new Db\ExceptionInput("circularDependence", $errData); } elseif (!$p['available']) { + // if a folder with the same parent and name already exists, throw another different exception throw new Db\ExceptionInput("constraintViolation", ["action" => $this->caller(), "field" => (is_null($name) ? "parent" : "name")]); } return $parent; @@ -438,7 +446,10 @@ class Database { 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"]); - } elseif($checkDuplicates) { + } elseif ($checkDuplicates) { + // make sure that a folder with the same prospective name and parent does not already exist: if the parent is null, + // SQL will happily accept duplicates (null is not unique), so we must do this check ourselves + $parent = $parent ? $parent : null; if ($this->db->prepare("SELECT exists(select id from arsse_folders where parent is ? and name is ?)", "int", "str")->run($parent, $name)->getValue()) { throw new Db\ExceptionInput("constraintViolation", ["action" => $this->caller(), "field" => "name"]); } @@ -470,10 +481,12 @@ class Database { return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } - public function subscriptionList(string $user, int $folder = null, int $id = null): Db\Result { + public function subscriptionList(string $user, $folder = null, int $id = null): Db\Result { 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 @@ -492,13 +505,11 @@ class Database { $q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); - if (!is_null($id)) { + if ($id) { // 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) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $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 @@ -507,24 +518,30 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } - public function subscriptionRemove(string $user, int $id): bool { + public function subscriptionRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { 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"]); + } $changes = $this->db->prepare("DELETE from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); if (!$changes) { - throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); } return true; } - public function subscriptionPropertiesGet(string $user, int $id): array { + public function subscriptionPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { 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"]); + } // disable authorization checks for the list call Arsse::$user->authorizationEnabled(false); - $sub = $this->subscriptionList($user, null, $id)->getRow(); + $sub = $this->subscriptionList($user, null, (int) $id)->getRow(); Arsse::$user->authorizationEnabled(true); if (!$sub) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); @@ -532,15 +549,13 @@ class Database { return $sub; } - public function subscriptionPropertiesSet(string $user, int $id, array $data): bool { + public function subscriptionPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $tr = $this->db->begin(); - if (!$this->db->prepare("SELECT count(*) from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->getValue()) { - // if the ID doesn't exist or doesn't belong to the user, throw an exception - throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); - } + // validate the ID + $id = $this->subscriptionValidateId($user, $id, true)['id']; if (array_key_exists("folder", $data)) { // ensure the target folder exists and belong to the user $data['folder'] = $this->folderValidateId($user, $data['folder'])['id']; @@ -570,10 +585,13 @@ class Database { return $out; } - protected function subscriptionValidateId(string $user, int $id): array { - $out = $this->db->prepare("SELECT feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); + 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"]); + } + $out = $this->db->prepare("SELECT id,feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); if (!$out) { - throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]); + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]); } return $out; } @@ -583,9 +601,12 @@ class Database { return array_column($feeds, 'id'); } - public function feedUpdate(int $feedID, bool $throwError = false): bool { + public function feedUpdate($feedID, bool $throwError = false): bool { $tr = $this->db->begin(); // check to make sure the feed exists + if (!ValueInfo::id($feedID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]); + } $f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id is ?", "int")->run($feedID)->getRow(); if (!$f) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]); @@ -596,7 +617,7 @@ class Database { // here. When an exception is thrown it should update the database with the // error instead of failing; if other exceptions are thrown, we should simply roll back try { - $feed = new Feed($feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape); + $feed = new Feed((int) $feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape); if (!$feed->modified) { // if the feed hasn't changed, just compute the next fetch time and record it $this->db->prepare("UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id is ?", 'datetime', 'int')->run($feed->nextFetch, $feedID); @@ -1004,7 +1025,10 @@ class Database { return true; } - protected function articleValidateId(string $user, int $id): array { + 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 + } $out = $this->db->prepare( "SELECT arsse_articles.id as article, @@ -1023,6 +1047,9 @@ 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 + } $out = $this->db->prepare( "SELECT arsse_editions.id as edition, diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index b47339e..d65ff2e 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -20,7 +20,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { // check to make sure required extension is loaded if (!class_exists("SQLite3")) { throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore - } + } // if no database file is specified in the configuration, use a suitable default $dbFile = Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; $mode = \SQLITE3_OPEN_READWRITE | \SQLITE3_OPEN_CREATE; diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 566d928..ca37b3d 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -37,7 +37,7 @@ class Context { protected function cleanArray(array $spec): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { - if(ValueInfo::int($spec[$a])===ValueInfo::VALID) { + if (ValueInfo::id($spec[$a])) { $spec[$a] = (int) $spec[$a]; } else { $spec[$a] = 0; diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index f6f85e9..040243d 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -13,33 +13,34 @@ class ValueInfo { const EMPTY = 1 << 2; const WHITE = 1 << 3; - static public function int($value): int { + public static function int($value): int { $out = 0; - // check if the input is null if (is_null($value)) { - $out += self::NULL; - } - // normalize the value to an integer or float if possible - if (is_string($value)) { - if (strval(@intval($value))===$value) { + // check if the input is null + return self::NULL; + } elseif (is_string($value) || (is_object($value) && method_exists($value, "__toString"))) { + $value = (string) $value; + // normalize a string an integer or float if possible + if (!strlen($value)) { + // the empty string is equivalent to null when evaluating an integer + return self::NULL; + } elseif (filter_var($value, \FILTER_VALIDATE_FLOAT) !== false && !fmod((float) $value, 1)) { + // an integral float is acceptable $value = (int) $value; - } elseif (strval(@floatval($value))===$value) { - $value = (float) $value; - } - // the empty string is equivalent to null when evaluating an integer - if (!strlen((string) $value)) { - $out += self::NULL; + } else { + return $out; } - } - // if the value is not an integer or integral float, stop - if (!is_int($value) && (!is_float($value) || fmod($value, 1))) { + } elseif (is_float($value) && !fmod($value, 1)) { + // an integral float is acceptable + $value = (int) $value; + } elseif (!is_int($value)) { + // if the value is not an integer or integral float, stop return $out; } // mark validity - $value = (int) $value; $out += self::VALID; // mark zeroness - if(!$value) { + if ($value==0) { $out += self::ZERO; } // mark negativeness @@ -49,14 +50,17 @@ class ValueInfo { return $out; } - static public function str($value): int { + public static function str($value): int { $out = 0; // check if the input is null if (is_null($value)) { $out += self::NULL; } - // if the value is not scalar, it cannot be valid - if (!is_scalar($value)) { + if (is_object($value) && method_exists($value, "__toString")) { + // if the value is an object which has a __toString method, this is acceptable + $value = (string) $value; + } elseif (!is_scalar($value) || is_bool($value) || (is_float($value) && !is_finite($value))) { + // otherwise if the value is not scalar, is a boolean, or is infinity or NaN, it cannot be valid return $out; } // mark validity @@ -70,4 +74,19 @@ class ValueInfo { } return $out; } -} \ No newline at end of file + + public static function id($value, bool $allowNull = false): bool { + $info = self::int($value); + if ($allowNull && ($info & self::NULL)) { // null (and allowed) + return true; + } elseif (!($info & self::VALID)) { // not an integer + return false; + } elseif ($info & self::NEG) { // negative integer + return false; + } elseif (!$allowNull && ($info & self::ZERO)) { // zero (and not allowed) + return false; + } else { // non-negative integer + return true; + } + } +} diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index 281c7c5..8c0988d 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -32,10 +32,6 @@ abstract class AbstractHandler implements Handler { return $data; } - protected function validateInt($id): bool { - return (bool) (ValueInfo::int($id) & ValueInfo::VALID); - } - protected function NormalizeInput(array $data, array $types, string $dateFormat = null): array { $out = []; foreach ($data as $key => $value) { @@ -49,34 +45,29 @@ abstract class AbstractHandler implements Handler { } switch ($types[$key]) { case "int": - if ($this->validateInt($value)) { + if (valueInfo::int($value) & ValueInfo::VALID) { $out[$key] = (int) $value; } break; case "string": - $out[$key] = (string) $value; + if (is_bool($value)) { + $out[$key] = var_export($value, true); + } elseif (!is_scalar($value)) { + break; + } else { + $out[$key] = (string) $value; + } break; case "bool": - if (is_bool($value)) { - $out[$key] = $value; - } elseif ($this->validateInt($value)) { - $value = (int) $value; - if ($value > -1 && $value < 2) { - $out[$key] = $value; - } - } elseif (is_string($value)) { - $value = trim(strtolower($value)); - if ($value=="false") { - $out[$key] = false; - } - if ($value=="true") { - $out[$key] = true; - } + $test = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + if (!is_null($test)) { + $out[$key] = $test; } break; case "float": - if (is_numeric($value)) { - $out[$key] = (float) $value; + $test = filter_var($value, \FILTER_VALIDATE_FLOAT); + if ($test !== false) { + $out[$key] = $test; } break; case "datetime": diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index f17a298..8ece274 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -6,6 +6,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; @@ -78,7 +79,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // dispatch try { return $this->$func($req->paths, $data); - // @codeCoverageIgnoreStart + // @codeCoverageIgnoreStart } catch (Exception $e) { // if there was a REST exception return 400 return new Response(400); @@ -94,15 +95,15 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { 'items' => [], 'folders' => [ '' => ['GET' => "folderList", 'POST' => "folderAdd"], - '0' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], - '0/read' => ['PUT' => "folderMarkRead"], + '1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], + '1/read' => ['PUT' => "folderMarkRead"], ], 'feeds' => [ '' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"], - '0' => ['DELETE' => "subscriptionRemove"], - '0/move' => ['PUT' => "subscriptionMove"], - '0/rename' => ['PUT' => "subscriptionRename"], - '0/read' => ['PUT' => "subscriptionMarkRead"], + '1' => ['DELETE' => "subscriptionRemove"], + '1/move' => ['PUT' => "subscriptionMove"], + '1/rename' => ['PUT' => "subscriptionRename"], + '1/read' => ['PUT' => "subscriptionMarkRead"], 'all' => ['GET' => "feedListStale"], 'update' => ['GET' => "feedUpdate"], ], @@ -110,12 +111,12 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { '' => ['GET' => "articleList"], 'updated' => ['GET' => "articleList"], 'read' => ['PUT' => "articleMarkReadAll"], - '0/read' => ['PUT' => "articleMarkRead"], - '0/unread' => ['PUT' => "articleMarkRead"], + '1/read' => ['PUT' => "articleMarkRead"], + '1/unread' => ['PUT' => "articleMarkRead"], 'read/multiple' => ['PUT' => "articleMarkReadMulti"], 'unread/multiple' => ['PUT' => "articleMarkReadMulti"], - '0/0/star' => ['PUT' => "articleMarkStarred"], - '0/0/unstar' => ['PUT' => "articleMarkStarred"], + '1/1/star' => ['PUT' => "articleMarkStarred"], + '1/1/unstar' => ['PUT' => "articleMarkStarred"], 'star/multiple' => ['PUT' => "articleMarkStarredMulti"], 'unstar/multiple' => ['PUT' => "articleMarkStarredMulti"], ], @@ -135,10 +136,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { ]; // the first path element is the overall scope of the request $scope = $url[0]; - // any URL components which are only digits should be replaced with "0", for easier comparison (integer segments are IDs, and we don't care about the specific ID) + // any URL components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID) for ($a = 0; $a < sizeof($url); $a++) { - if ($this->validateInt($url[$a])) { - $url[$a] = "0"; + if (ValueInfo::id($url[$a])) { + $url[$a] = "1"; } } // normalize the HTTP method to uppercase @@ -336,7 +337,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { try { Arsse::$db->feedUpdate($data['feedId']); } catch (ExceptionInput $e) { - return new Response(404); + switch ($e->getCode()) { + case 10239: // feed does not exist + return new Response(404); + case 10237: // feed ID invalid + return new Response(422); + default: // other errors related to input + return new Response(400); // @codeCoverageIgnore + } } return new Response(204); } @@ -347,8 +355,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if (!isset($data['url'])) { return new Response(422); } - // normalize the folder ID, if specified; zero should be transformed to null - $folder = (isset($data['folderId']) && $data['folderId']) ? $data['folderId'] : null; + // normalize the folder ID, if specified + $folder = isset($data['folderId']) ? $data['folderId'] : null; // try to add the feed $tr = Arsse::$db->begin(); try { @@ -446,12 +454,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in); } catch (ExceptionInput $e) { switch ($e->getCode()) { - // subscription does not exist - case 10239: return new Response(404); - // folder does not exist - case 10235: return new Response(422); - // other errors related to input - default: return new Response(400); // @codeCoverageIgnore + case 10239: // subscription does not exist + return new Response(404); + case 10235: // folder does not exist + case 10237: // folder ID is invalid + return new Response(422); + default: // other errors related to input + return new Response(400); // @codeCoverageIgnore } } return new Response(204); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index e34081d..a8e3e8d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -58,7 +58,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $method = strtolower($method); $map = get_class_methods($this); $map = array_combine(array_map("strtolower", $map), $map); - if(!array_key_exists($method, $map)) { + if (!array_key_exists($method, $map)) { // if the method really doesn't exist, throw an exception throw new Exception("UNKNWON_METHOD", ['method' => $data['op']]); } @@ -113,7 +113,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if (isset($data['user']) && isset($data['password']) && Arsse::$user->auth($data['user'], $data['password'])) { $id = Arsse::$db->sessionCreate($data['user']); return [ - 'session_id' => $id, + 'session_id' => $id, 'api_level' => self::LEVEL ]; } else { @@ -144,7 +144,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } catch (ExceptionInput $e) { switch ($e->getCode()) { // folder already exists - case 10236: + case 10236: // retrieve the ID of the existing folder; duplicating a category silently returns the existing one $folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false); foreach ($folders as $folder) { @@ -159,4 +159,4 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } } -} \ No newline at end of file +} diff --git a/lib/REST/TinyTinyRSS/Exception.php b/lib/REST/TinyTinyRSS/Exception.php index bc4bb8b..8833efa 100644 --- a/lib/REST/TinyTinyRSS/Exception.php +++ b/lib/REST/TinyTinyRSS/Exception.php @@ -14,4 +14,4 @@ class Exception extends \Exception { $err = ['error' => $this->getMessage()]; return array_merge($err, $this->data, $err); } -} \ No newline at end of file +} diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index f03ca41..49ab9bd 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -47,9 +47,9 @@ class TestContext extends Test\AbstractTest { $this->assertInstanceOf(Context::class, $c->$method($v[$method])); $this->assertTrue($c->$method()); if (in_array($method, $times)) { - $this->assertTime($c->$method, $v[$method]); + $this->assertTime($c->$method, $v[$method], "Context method $method did not return the expected results"); } else { - $this->assertSame($c->$method, $v[$method]); + $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results"); } } } diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php new file mode 100644 index 0000000..1a75099 --- /dev/null +++ b/tests/Misc/TestValueInfo.php @@ -0,0 +1,204 @@ +assertSame($exp, I::int($value), "Test returned ".decbin(I::int($value))." for value: ".var_export($value, true)); + } + } + public function testGetStringInfo() { + $tests = [ + [null, I::NULL], + ["", I::VALID | I::EMPTY], + [1, I::VALID], + [PHP_INT_MAX, I::VALID], + [1.0, I::VALID], + ["1.0", I::VALID], + ["001.0", I::VALID], + ["1.0e2", I::VALID], + ["1", I::VALID], + ["001", I::VALID], + ["1e2", I::VALID], + ["+1.0", I::VALID], + ["+001.0", I::VALID], + ["+1.0e2", I::VALID], + ["+1", I::VALID], + ["+001", I::VALID], + ["+1e2", I::VALID], + [0, I::VALID], + ["0", I::VALID], + ["000", I::VALID], + [0.0, I::VALID], + ["0.0", I::VALID], + ["000.000", I::VALID], + ["+0", I::VALID], + ["+000", I::VALID], + ["+0.0", I::VALID], + ["+000.000", I::VALID], + [-1, I::VALID], + [-1.0, I::VALID], + ["-1.0", I::VALID], + ["-001.0", I::VALID], + ["-1.0e2", I::VALID], + ["-1", I::VALID], + ["-001", I::VALID], + ["-1e2", I::VALID], + [-0, I::VALID], + ["-0", I::VALID], + ["-000", I::VALID], + [-0.0, I::VALID], + ["-0.0", I::VALID], + ["-000.000", I::VALID], + [false, 0], + [true, 0], + [INF, 0], + [-INF, 0], + [NAN, 0], + [[], 0], + ["some string", I::VALID], + [" ", I::VALID | I::WHITE], + [new \StdClass, 0], + [new StrClass(""), I::VALID | I::EMPTY], + [new StrClass("1"), I::VALID], + [new StrClass("0"), I::VALID], + [new StrClass("-1"), I::VALID], + [new StrClass("Msg"), I::VALID], + [new StrClass(" "), I::VALID | I::WHITE], + ]; + foreach ($tests as $test) { + list($value, $exp) = $test; + $this->assertSame($exp, I::str($value), "Test returned ".decbin(I::str($value))." for value: ".var_export($value, true)); + } + } + + public function testValidateDatabaseIdentifier() { + $tests = [ + [null, false, true], + ["", false, true], + [1, true, true], + [PHP_INT_MAX, true, true], + [1.0, true, true], + ["1.0", true, true], + ["001.0", true, true], + ["1.0e2", true, true], + ["1", true, true], + ["001", true, true], + ["1e2", true, true], + ["+1.0", true, true], + ["+001.0", true, true], + ["+1.0e2", true, true], + ["+1", true, true], + ["+001", true, true], + ["+1e2", true, true], + [0, false, true], + ["0", false, true], + ["000", false, true], + [0.0, false, true], + ["0.0", false, true], + ["000.000", false, true], + ["+0", false, true], + ["+000", false, true], + ["+0.0", false, true], + ["+000.000", false, true], + [-1, false, false], + [-1.0, false, false], + ["-1.0", false, false], + ["-001.0", false, false], + ["-1.0e2", false, false], + ["-1", false, false], + ["-001", false, false], + ["-1e2", false, false], + [-0, false, true], + ["-0", false, true], + ["-000", false, true], + [-0.0, false, true], + ["-0.0", false, true], + ["-000.000", false, true], + [false, false, false], + [true, false, false], + [INF, false, false], + [-INF, false, false], + [NAN, false, false], + [[], false, false], + ["some string", false, false], + [" ", false, false], + [new \StdClass, false, false], + [new StrClass(""), false, true], + [new StrClass("1"), true, true], + [new StrClass("0"), false, true], + [new StrClass("-1"), false, false], + [new StrClass("Msg"), false, false], + [new StrClass(" "), false, false], + ]; + foreach ($tests as $test) { + list($value, $exp, $expNull) = $test; + $this->assertSame($exp, I::id($value), "Non-null test failed for value: ".var_export($value, true)); + $this->assertSame($expNull, I::id($value, true), "Null test failed for value: ".var_export($value, true)); + } + } +} diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index a137df9..713267b 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -46,6 +46,21 @@ class TestNCNV1_2 extends Test\AbstractTest { 'title' => 'Second example feed', 'unread' => 23, ], + [ + 'id' => 47, + 'url' => 'http://example.net/news.atom', + 'favicon' => 'http://example.net/favicon.png', + 'source' => 'http://example.net/', + 'folder' => null, + 'top_folder' => null, + 'pinned' => 0, + 'err_count' => 0, + 'err_msg' => '', + 'order_type' => 1, + 'added' => '2017-05-20 13:35:54', + 'title' => 'Third example feed', + 'unread' => 0, + ], ], 'rest' => [ [ @@ -76,6 +91,20 @@ class TestNCNV1_2 extends Test\AbstractTest { 'title' => 'Second example feed', 'unreadCount' => 23, ], + [ + 'id' => 47, + 'url' => 'http://example.net/news.atom', + 'faviconLink' => 'http://example.net/favicon.png', + 'link' => 'http://example.net/', + 'folderId' => 0, + 'pinned' => false, + 'updateErrorCount' => 0, + 'lastUpdateError' => '', + 'ordering' => 1, + 'added' => 1495287354, + 'title' => 'Third example feed', + 'unreadCount' => 0, + ], ], ]; protected $articles = [ @@ -331,7 +360,7 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/json'))); } - public function testReceiveAuthenticationChallenge() { + public function testSendAuthenticationChallenge() { Phake::when(Arsse::$user)->authHTTP->thenReturn(false); $exp = new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.REST\NextCloudNews\V1_2::REALM.'"']); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/"))); @@ -381,6 +410,7 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders"))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json'))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":{}}', 'application/json'))); // try adding the same two folders again $exp = new Response(409); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); @@ -457,26 +487,29 @@ class TestNCNV1_2 extends Test\AbstractTest { $in = [ ['url' => "http://example.com/news.atom", 'folderId' => 3], ['url' => "http://example.org/news.atom", 'folderId' => 8], - ['url' => "http://example.net/news.atom", 'folderId' => 0], + ['url' => "http://example.net/news.atom", 'folderId' => 8], + ['url' => "http://example.net/news.atom", 'folderId' => -1], [], ]; $out = [ ['feeds' => [$this->feeds['rest'][0]]], ['feeds' => [$this->feeds['rest'][1]], 'newestItemId' => 4758915], - [], - [], + ['feeds' => [$this->feeds['rest'][2]], 'newestItemId' => 2112], ]; // set up the necessary mocks Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.com/news.atom")->thenReturn(2112)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.org/news.atom")->thenReturn(42)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->feeds['db'][0]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->feeds['db'][1]); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->feeds['db'][2]); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(2112))->thenReturn(0); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(42))->thenReturn(4758915); - Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, ['folder' => 3])->thenThrow(new ExceptionInput("idMissing")); // folder ID 3 does not exist - Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true); - // set up a mock for a bad feed - Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException())); + Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(47))->thenReturn(2112); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, ['folder' => 3])->thenThrow(new ExceptionInput("idMissing")); // folder ID 3 does not exist + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 47, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder ID -1 is invalid + // set up a mock for a bad feed which succeeds the second time + Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47); // add the subscriptions $exp = new Response(200, $out[0]); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); @@ -485,14 +518,16 @@ class TestNCNV1_2 extends Test\AbstractTest { // try to add them a second time $exp = new Response(409); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); - $exp = new Response(409); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); // try to add a bad feed $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); + // try again (this will succeed), with an invalid folder ID + $exp = new Response(200, $out[2]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); // try to add no feed $exp = new Response(422); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[4]), 'application/json'))); } public function testRemoveASubscription() { @@ -511,11 +546,13 @@ class TestNCNV1_2 extends Test\AbstractTest { ['folderId' => 42], ['folderId' => 2112], ['folderId' => 42], + ['folderId' => -1], [], ]; - Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 42])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 42])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => null])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist + Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); @@ -527,6 +564,8 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); + $exp = new Response(422); + $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json'))); } public function testRenameASubscription() { @@ -584,18 +623,20 @@ class TestNCNV1_2 extends Test\AbstractTest { ['feedId' => 42], // valid ['feedId' => 2112], // feed does not exist ['feedId' => "ook"], // invalid ID + ['feedId' => -1], // invalid ID ['feed' => 42], // invalid input ]; Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->feedUpdate(-1)->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $exp = new Response(404); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json'))); - $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json'))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[4]), 'application/json'))); // updating a feed when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new Response(403); @@ -606,13 +647,15 @@ class TestNCNV1_2 extends Test\AbstractTest { $res = new Result($this->articles['db']); $t = new \DateTime; $in = [ - ['type' => 0, 'id' => 42], - ['type' => 1, 'id' => 2112], - ['type' => 2, 'id' => 0], - ['type' => 3, 'id' => 0], + ['type' => 0, 'id' => 42], // type=0 => subscription/feed + ['type' => 1, 'id' => 2112], // type=1 => folder + ['type' => 0, 'id' => -1], // type=0 => subscription/feed; invalid ID + ['type' => 1, 'id' => -1], // type=1 => folder; invalid ID + ['type' => 2, 'id' => 0], // type=2 => starred + ['type' => 3, 'id' => 0], // type=3 => all (default); base context ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], - ['getRead' => true], + ['getRead' => true], // base context ['getRead' => false], ['lastModified' => $t->getTimestamp()], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context @@ -620,6 +663,8 @@ class TestNCNV1_2 extends Test\AbstractTest { 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")); $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 @@ -628,22 +673,26 @@ class TestNCNV1_2 extends Test\AbstractTest { $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json'))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json'))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json'))); // simply run through the remainder of the input for later method verification - $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json')); // third instance of base context $this->h->dispatch(new Request("GET", "/items", json_encode($in[4]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json')); // fourth instance of base context + $this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); // third instance of base context + $this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[7]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); + $this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); // fourth instance of base context $this->h->dispatch(new Request("GET", "/items", json_encode($in[9]), 'application/json')); + $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)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4)); + 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)); diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index ebb42b8..a712b13 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -270,7 +270,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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]])); + 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, [])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => "", 'parent' => null])->thenThrow(new ExceptionInput("missing")); @@ -286,11 +286,11 @@ class TestTinyTinyAPI extends Test\AbstractTest { $exp = $this->respGood(3); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); - Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); + Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); // add some invalid folders $exp = $this->respErr("INCORRECT_USAGE"); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); } -} \ No newline at end of file +} diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 391ae7a..a310cda 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -25,10 +25,10 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } - public function assertTime($exp, $test) { + public function assertTime($exp, $test, string $msg = null) { $exp = Date::transform($exp, "iso8601"); $test = Date::transform($test, "iso8601"); - $this->assertSame($exp, $test); + $this->assertSame($exp, $test, $msg); } public function clearData(bool $loadLang = true): bool { diff --git a/tests/lib/Database/SeriesCleanup.php b/tests/lib/Database/SeriesCleanup.php index 9d60c8b..a8656ff 100644 --- a/tests/lib/Database/SeriesCleanup.php +++ b/tests/lib/Database/SeriesCleanup.php @@ -192,6 +192,5 @@ trait SeriesCleanup { unset($state['arsse_sessions']['rows'][$id - 1]); } $this->compareExpectations($state); - } } diff --git a/tests/lib/Database/SeriesFeed.php b/tests/lib/Database/SeriesFeed.php index d2cd7ed..09312e5 100644 --- a/tests/lib/Database/SeriesFeed.php +++ b/tests/lib/Database/SeriesFeed.php @@ -221,6 +221,11 @@ trait SeriesFeed { Arsse::$db->feedUpdate(2112); } + public function testUpdateAnInvalidFeed() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->feedUpdate(-1); + } + public function testUpdateAFeedThrowingExceptions() { $this->assertException("invalidUrl", "Feed"); Arsse::$db->feedUpdate(3, true); diff --git a/tests/lib/Database/SeriesFolder.php b/tests/lib/Database/SeriesFolder.php index 16312f2..5c0d15b 100644 --- a/tests/lib/Database/SeriesFolder.php +++ b/tests/lib/Database/SeriesFolder.php @@ -77,7 +77,7 @@ trait SeriesFolder { } public function testAddANestedFolderToAnInvalidParent() { - $this->assertException("idMissing", "Db", "ExceptionInput"); + $this->assertException("typeViolation", "Db", "ExceptionInput"); Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => "stringFolderId"]); } @@ -184,6 +184,11 @@ trait SeriesFolder { Arsse::$db->folderRemove("john.doe@example.com", 2112); } + public function testRemoveAnInvalidFolder() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->folderRemove("john.doe@example.com", -1); + } + public function testRemoveAFolderOfTheWrongOwner() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->folderRemove("john.doe@example.com", 4); // folder ID 4 belongs to Jane @@ -210,6 +215,11 @@ trait SeriesFolder { Arsse::$db->folderPropertiesGet("john.doe@example.com", 2112); } + public function testGetThePropertiesOfAnInvalidFolder() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->folderPropertiesGet("john.doe@example.com", -1); + } + public function testGetThePropertiesOfAFolderOfTheWrongOwner() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->folderPropertiesGet("john.doe@example.com", 4); // folder ID 4 belongs to Jane @@ -233,6 +243,10 @@ trait SeriesFolder { $this->compareExpectations($state); } + public function testRenameTheRootFolder() { + $this->assertFalse(Arsse::$db->folderPropertiesSet("john.doe@example.com", null, ['name' => "Opinion"])); + } + public function testRenameAFolderToTheEmptyString() { $this->assertException("missing", "Db", "ExceptionInput"); $this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => ""])); @@ -296,6 +310,11 @@ trait SeriesFolder { Arsse::$db->folderPropertiesSet("john.doe@example.com", 2112, ['parent' => null]); } + public function testSetThePropertiesOfAnInvalidFolder() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->folderPropertiesSet("john.doe@example.com", -1, ['parent' => null]); + } + public function testSetThePropertiesOfAFolderForTheWrongOwner() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->folderPropertiesSet("john.doe@example.com", 4, ['parent' => null]); // folder ID 4 belongs to Jane diff --git a/tests/lib/Database/SeriesSession.php b/tests/lib/Database/SeriesSession.php index 95d4b1b..20fada6 100644 --- a/tests/lib/Database/SeriesSession.php +++ b/tests/lib/Database/SeriesSession.php @@ -7,7 +7,6 @@ use JKingWeb\Arsse\Misc\Date; use Phake; trait SeriesSession { - public function setUpSeries() { // set up the test data $past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute")); diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 12f5760..b68ecb4 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -193,6 +193,11 @@ trait SeriesSubscription { Arsse::$db->subscriptionRemove($this->user, 2112); } + public function testRemoveAnInvalidSubscription() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->subscriptionRemove($this->user, -1); + } + public function testRemoveASubscriptionForTheWrongOwner() { $this->user = "jane.doe@example.com"; $this->assertException("subjectMissing", "Db", "ExceptionInput"); @@ -264,6 +269,11 @@ trait SeriesSubscription { Arsse::$db->subscriptionPropertiesGet($this->user, 2112); } + public function testGetThePropertiesOfAnInvalidSubscription() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->subscriptionPropertiesGet($this->user, -1); + } + public function testGetThePropertiesOfASubscriptionWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); @@ -311,7 +321,7 @@ trait SeriesSubscription { } public function testRenameASubscriptionToFalse() { - $this->assertException("missing", "Db", "ExceptionInput"); + $this->assertException("typeViolation", "Db", "ExceptionInput"); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]); } @@ -329,6 +339,11 @@ trait SeriesSubscription { Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]); } + public function testSetThePropertiesOfAnInvalidSubscription() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->subscriptionPropertiesSet($this->user, -1, ['folder' => null]); + } + public function testSetThePropertiesOfASubscriptionWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); diff --git a/tests/lib/Misc/StrClass.php b/tests/lib/Misc/StrClass.php new file mode 100644 index 0000000..905194f --- /dev/null +++ b/tests/lib/Misc/StrClass.php @@ -0,0 +1,15 @@ +str = (string) $str; + } + + public function __toString() { + return $this->str; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 98eba09..d1e8ea0 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -31,6 +31,10 @@ Conf/TestConf.php + + Misc/TestValueInfo.php + Misc/TestContext.php + User/TestUserMockInternal.php User/TestUserMockExternal.php @@ -41,9 +45,6 @@ Feed/TestFeedFetching.php Feed/TestFeed.php - - Misc/TestContext.php - Db/TestTransaction.php Db/SQLite3/TestDbResultSQLite3.php From 0a0aabe4edad8ad1423f98438ff5b72739921ba3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Sep 2017 12:52:05 -0400 Subject: [PATCH 10/66] Merge master --- .gitattributes | 31 ++++------- .gitignore | 53 +++++++++---------- CHANGELOG | 12 +++++ bootstrap.php | 2 +- build.xml | 1 + dist/nginx-fcgi.conf | 3 +- lib/CLI.php | 2 +- lib/Database.php | 8 +-- lib/Feed.php | 13 ++--- lib/REST.php | 8 ++- lib/REST/NextCloudNews/Versions.php | 2 +- lib/REST/Request.php | 5 ++ lib/REST/Response.php | 26 +++++---- tests/Feed/TestFeed.php | 9 ++++ .../NextCloudNews/TestNCNVersionDiscovery.php | 2 +- tests/docroot/Feed/Discovery/Feed.php | 12 +++++ tests/docroot/Feed/Discovery/Invalid.php | 8 +++ tests/docroot/Feed/Discovery/Valid.php | 9 ++++ tests/lib/Database/SeriesSubscription.php | 4 +- 19 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 CHANGELOG create mode 100644 tests/docroot/Feed/Discovery/Feed.php create mode 100644 tests/docroot/Feed/Discovery/Invalid.php create mode 100644 tests/docroot/Feed/Discovery/Valid.php diff --git a/.gitattributes b/.gitattributes index 2431c40..7df9c77 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,22 +1,13 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text=auto encoding=utf-8 -# Custom for Visual Studio -*.cs diff=csharp -*.sln merge=union -*.csproj merge=union -*.vbproj merge=union -*.fsproj merge=union -*.dbproj merge=union +*.html diff=html +*.php diff=php +*.bat eol=crlf +.gitignore -eol -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain + +tests/ export-ignore +.* export-ignore +build.xml export-ignore +composer.* export-ignore +phpdoc.* export-ignore diff --git a/.gitignore b/.gitignore index 9ac5f60..005d958 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,45 @@ -#dependencies -vendor/ -#temp files +# Temporary files and dependencies + +vendor/ documentation/ -tests/coverage +tests/coverage/ +build/ arsse.db* config.php .php_cs.cache -build -# Windows image file caches + + + +# Windows files + Thumbs.db ehthumbs.db - -# Folder config file Desktop.ini - -# Recycle Bin used on file shares $RECYCLE.BIN/ -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# ========================= -# Operating System Files -# ========================= -# OSX -# ========================= +# macOS files .DS_Store .AppleDouble .LSOverride - -# Icon must ends with two \r. Icon - - -# Thumbnails ._* - -# Files that might appear on external disk .Spotlight-V100 .Trashes + +# archives + +*.zip +*.7z +*.tar.gz +*.tgz +*.deb +*.rpm +*.dmg +*.cab +*.msi +*.msm +*.msp diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..451ce8a --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,12 @@ +Version 0.1.1 (2017-09-30) +========================== + +Bug fixes: +- Perform feed discovery like NextCloud News does +- Respond correctly to HEAD requests +- Various minor fixes + +Version 0.1.0 (2017-08-29) +========================== + +Initial release \ No newline at end of file diff --git a/bootstrap.php b/bootstrap.php index 8ba3a73..04b4f0b 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -4,7 +4,7 @@ namespace JKingWeb\Arsse; const BASE = __DIR__.DIRECTORY_SEPARATOR; const NS_BASE = __NAMESPACE__."\\"; -const VERSION = "0.1.0"; +const VERSION = "0.1.1"; require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; ignore_user_abort(true); \ No newline at end of file diff --git a/build.xml b/build.xml index 5524305..25753f7 100644 --- a/build.xml +++ b/build.xml @@ -11,6 +11,7 @@ + diff --git a/dist/nginx-fcgi.conf b/dist/nginx-fcgi.conf index fc76d31..fb37825 100644 --- a/dist/nginx-fcgi.conf +++ b/dist/nginx-fcgi.conf @@ -9,4 +9,5 @@ fastcgi_param REQUEST_METHOD $request_method; fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_LENGTH $content_length; fastcgi_param REQUEST_URI $request_uri; -fastcgi_param HTTPS $https if_not_empty; \ No newline at end of file +fastcgi_param HTTPS $https if_not_empty; +fastcgi_param REMOTE_USER $remote_user; \ No newline at end of file diff --git a/lib/CLI.php b/lib/CLI.php index 56dfbad..971197f 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -84,7 +84,7 @@ USAGE_TEXT; public function userAdd(string $user, string $password = null): int { $passwd = Arsse::$user->add($user, $password); if (is_null($password)) { - echo $passwd; + echo $passwd.\PHP_EOL; } return 0; } diff --git a/lib/Database.php b/lib/Database.php index bf6ce12..5a90b19 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -459,7 +459,7 @@ class Database { } } - public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = ""): int { + public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } @@ -470,7 +470,7 @@ class Database { $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); try { // perform an initial update on the newly added feed - $this->feedUpdate($feedID, true); + $this->feedUpdate($feedID, true, $discover); } catch (\Throwable $e) { // if the update fails, delete the feed we just added $this->db->prepare('DELETE from arsse_feeds where id is ?', 'int')->run($feedID); @@ -601,7 +601,7 @@ class Database { return array_column($feeds, 'id'); } - public function feedUpdate($feedID, bool $throwError = false): bool { + public function feedUpdate($feedID, bool $throwError = false, bool $discover = false): bool { $tr = $this->db->begin(); // check to make sure the feed exists if (!ValueInfo::id($feedID)) { @@ -617,7 +617,7 @@ class Database { // here. When an exception is thrown it should update the database with the // error instead of failing; if other exceptions are thrown, we should simply roll back try { - $feed = new Feed((int) $feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape); + $feed = new Feed((int) $feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape, $discover); if (!$feed->modified) { // if the feed hasn't changed, just compute the next fetch time and record it $this->db->prepare("UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id is ?", 'datetime', 'int')->run($feed->nextFetch, $feedID); diff --git a/lib/Feed.php b/lib/Feed.php index a4dc476..ee6114c 100644 --- a/lib/Feed.php +++ b/lib/Feed.php @@ -21,7 +21,7 @@ class Feed { public $newItems = []; public $changedItems = []; - public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) { + public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false, bool $discover = false) { // set the configuration $userAgent = Arsse::$conf->fetchUserAgentString ?? sprintf('Arsse/%s (%s %s; %s; https://code.jkingweb.ca/jking/arsse) PicoFeed (https://github.com/fguillot/picoFeed)', VERSION, // Arsse version @@ -36,7 +36,7 @@ class Feed { $this->config->setClientUserAgent($userAgent); $this->config->setGrabberUserAgent($userAgent); // fetch the feed - $this->download($url, $lastModified, $etag, $username, $password); + $this->download($url, $lastModified, $etag, $username, $password, $discover); // format the HTTP Last-Modified date returned $lastMod = $this->resource->getLastModified(); if (strlen($lastMod)) { @@ -65,10 +65,11 @@ class Feed { $this->nextFetch = $this->computeNextFetch(); } - protected function download(string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = ''): bool { + protected function download(string $url, string $lastModified, string $etag, string $username, string $password, bool $discover): bool { + $action = $discover ? "discover" : "download"; try { $this->reader = new Reader($this->config); - $this->resource = $this->reader->download($url, $lastModified, $etag, $username, $password); + $this->resource = $this->reader->$action($url, $lastModified, $etag, $username, $password); } catch (PicoFeedException $e) { throw new Feed\Exception($url, $e); } @@ -361,13 +362,13 @@ class Feed { protected function computeLastModified() { if (!$this->modified) { - return $this->lastModified; + return $this->lastModified; // @codeCoverageIgnore } $dates = $this->gatherDates(); if (sizeof($dates)) { return Date::normalize($dates[0]); } else { - return null; + return null; // @codeCoverageIgnore } } diff --git a/lib/REST.php b/lib/REST.php index 28b9985..15bfac7 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -43,7 +43,13 @@ class REST { $req->refreshURL(); $class = $this->apis[$api]['class']; $drv = new $class(); - return $drv->dispatch($req); + if ($req->head) { + $res = $drv->dispatch($req); + $res->head = true; + return $res; + } else { + return $drv->dispatch($req); + } } public function apiMatch(string $url, array $map): string { diff --git a/lib/REST/NextCloudNews/Versions.php b/lib/REST/NextCloudNews/Versions.php index df260cc..9d5029d 100644 --- a/lib/REST/NextCloudNews/Versions.php +++ b/lib/REST/NextCloudNews/Versions.php @@ -11,7 +11,7 @@ class Versions implements \JKingWeb\Arsse\REST\Handler { public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { // if a method other than GET was used, this is an error if ($req->method != "GET") { - return new Response(405); + return new Response(405, "", "", ["Allow: GET"]); } if (preg_match("<^/?$>", $req->path)) { // if the request path is an empty string or just a slash, return the supported versions diff --git a/lib/REST/Request.php b/lib/REST/Request.php index bdd3a39..c21ca48 100644 --- a/lib/REST/Request.php +++ b/lib/REST/Request.php @@ -4,6 +4,7 @@ namespace JKingWeb\Arsse\REST; class Request { public $method = "GET"; + public $head = false; public $url = ""; public $path =""; public $paths = []; @@ -26,6 +27,10 @@ class Request { $this->url = $url; $this->body = $body; $this->type = $contentType; + if ($this->method=="HEAD") { + $this->head = true; + $this->method = "GET"; + } $this->refreshURL(); } diff --git a/lib/REST/Response.php b/lib/REST/Response.php index fc18723..5323695 100644 --- a/lib/REST/Response.php +++ b/lib/REST/Response.php @@ -9,6 +9,7 @@ class Response { const T_XML = "application/xml"; const T_TEXT = "text/plain"; + public $head = false; public $code; public $payload; public $type; @@ -24,15 +25,11 @@ class Response { public function output() { if (!headers_sent()) { - try { - $statusText = Arsse::$lang->msg("HTTP.Status.".$this->code); - } catch (\JKingWeb\Arsse\Lang\Exception $e) { - $statusText = ""; + foreach ($this->fields as $field) { + header($field); } - header("Status: ".$this->code." ".$statusText); $body = ""; if (!is_null($this->payload)) { - header("Content-Type: ".$this->type); switch ($this->type) { case self::T_JSON: $body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT); @@ -42,10 +39,21 @@ class Response { break; } } - foreach ($this->fields as $field) { - header($field); + if (strlen($body)) { + header("Content-Type: ".$this->type); + header("Content-Length: ".strlen($body)); + } elseif ($this->code==200) { + $this->code = 204; + } + try { + $statusText = Arsse::$lang->msg("HTTP.Status.".$this->code); + } catch (\JKingWeb\Arsse\Lang\Exception $e) { + $statusText = ""; + } + header("Status: ".$this->code." ".$statusText); + if (!$this->head) { + echo $body; } - echo $body; } else { throw new REST\Exception("headersSent"); } diff --git a/tests/Feed/TestFeed.php b/tests/Feed/TestFeed.php index 74c2426..8957ba8 100644 --- a/tests/Feed/TestFeed.php +++ b/tests/Feed/TestFeed.php @@ -133,6 +133,15 @@ class TestFeed extends Test\AbstractTest { $this->assertSame($categories, $f->data->items[5]->categories); } + public function testDiscoverAFeedSuccessfully() { + $this->assertInstanceOf(Feed::class, new Feed(null, $this->base."Discovery/Valid", "", "", "", "", false, true)); + } + + public function testDiscoverAFeedUnsuccessfully() { + $this->assertException("subscriptionNotFound", "Feed"); + new Feed(null, $this->base."Discovery/Invalid", "", "", "", "", false, true); + } + public function testParseEntityExpansionAttack() { $this->assertException("xmlEntity", "Feed"); new Feed(null, $this->base."Parsing/XEEAttack"); diff --git a/tests/REST/NextCloudNews/TestNCNVersionDiscovery.php b/tests/REST/NextCloudNews/TestNCNVersionDiscovery.php index ad3f15a..ddef865 100644 --- a/tests/REST/NextCloudNews/TestNCNVersionDiscovery.php +++ b/tests/REST/NextCloudNews/TestNCNVersionDiscovery.php @@ -26,7 +26,7 @@ class TestNCNVersionDiscovery extends Test\AbstractTest { } public function testUseIncorrectMethod() { - $exp = new Response(405); + $exp = new Response(405, "", "", ["Allow: GET"]); $h = new REST\NextCloudNews\Versions(); $req = new Request("POST", "/"); $res = $h->dispatch($req); diff --git a/tests/docroot/Feed/Discovery/Feed.php b/tests/docroot/Feed/Discovery/Feed.php new file mode 100644 index 0000000..a13398a --- /dev/null +++ b/tests/docroot/Feed/Discovery/Feed.php @@ -0,0 +1,12 @@ + "application/rss+xml", + 'content' => << + + Test feed + http://example.com/ + Example newsfeed title + + +MESSAGE_BODY +]; diff --git a/tests/docroot/Feed/Discovery/Invalid.php b/tests/docroot/Feed/Discovery/Invalid.php new file mode 100644 index 0000000..9a2f49f --- /dev/null +++ b/tests/docroot/Feed/Discovery/Invalid.php @@ -0,0 +1,8 @@ + "text/html", + 'content' => << +Example article + +MESSAGE_BODY +]; diff --git a/tests/docroot/Feed/Discovery/Valid.php b/tests/docroot/Feed/Discovery/Valid.php new file mode 100644 index 0000000..9f34f71 --- /dev/null +++ b/tests/docroot/Feed/Discovery/Valid.php @@ -0,0 +1,9 @@ + "text/html", + 'content' => << +Example article + + +MESSAGE_BODY +]; diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index b68ecb4..fc528c3 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -135,7 +135,7 @@ trait SeriesSubscription { Phake::when(Arsse::$db)->feedUpdate->thenReturn(true); $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url)); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); - Phake::verify(Arsse::$db)->feedUpdate($feedID, true); + Phake::verify(Arsse::$db)->feedUpdate($feedID, true, true); $state = $this->primeExpectations($this->data, [ 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], @@ -153,7 +153,7 @@ trait SeriesSubscription { Arsse::$db->subscriptionAdd($this->user, $url); } catch (FeedException $e) { Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); - Phake::verify(Arsse::$db)->feedUpdate($feedID, true); + Phake::verify(Arsse::$db)->feedUpdate($feedID, true, true); $state = $this->primeExpectations($this->data, [ 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], From 91cce6b529e71944f8c54571e7bf66b72daca0e7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Sep 2017 22:15:55 -0400 Subject: [PATCH 11/66] Implement all TTRSS feed and category handling except subscribing to feeds - Fixes #93 - Fixes #100 - Fixes #101 - Fixes #102 - Fixes #103 - Fixes #104 --- lib/REST/TinyTinyRSS/API.php | 133 ++++++++++++- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 208 +++++++++++++++++++++ 2 files changed, 334 insertions(+), 7 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a8e3e8d..0db6601 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -6,11 +6,24 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; +/* + +Protocol difference so far: + - handling of incorrect Content-Type and/or HTTP method is different + - TT-RSS accepts whitespace-only names; we do not + - TT-RSS allows two folders to share the same name under the same parent; we do not + - Session lifetime is much shorter by default (does TT-RSS even expire sessions?) + +*/ + + + class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; @@ -143,20 +156,126 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return Arsse::$db->folderAdd(Arsse::$user->id, $in); } catch (ExceptionInput $e) { switch ($e->getCode()) { - // folder already exists - case 10236: - // retrieve the ID of the existing folder; duplicating a category silently returns the existing one + 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 (int) $folder['id']; } } - // parent folder does not exist; this returns false as an ID - case 10235: return false; - // other errors related to input - default: throw new Exception("INCORRECT_USAGE"); + return false; + 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 (!isset($data['category_id']) || !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 (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['parent_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) { + if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['caption'])) { + // if the folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $info = ValueInfo::str($data['caption']); + if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the folder name is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => (string) $data['caption'], + ]; + try { + // try to rename the folder + Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opUnsubscribeFeed(array $data): array { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { + // if the feed is invalid, throw an error + throw new Exception("FEED_NOT_FOUND"); + } + 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 (!isset($data['feed_id']) || !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' => (int) $data['category_id'], + ]; + try { + // try to move the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameFeed(array $data) { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['caption'])) { + // if the feed is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $info = ValueInfo::str($data['caption']); + if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the feed name is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => (string) $data['caption'], + ]; + try { + // try to rename the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index a712b13..37aa594 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -293,4 +293,212 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); } + + 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // try deleting it again (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // delete a folder which does not exist (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // delete an invalid folder (causes an error) + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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]], + ]; + 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")); + // succefully move a folder + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // move a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // move a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(4))->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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // rename a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // rename a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + 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("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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // try deleting it again (this should noisily fail, as should everything else) + $exp = $this->respErr("FEED_NOT_FOUND"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + Phake::verify(Arsse::$db, Phake::times(3))->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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // move a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // move a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // rename a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // rename a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(3))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } } From 97b0134e56375e3ccb2b8c03ece0d65fb9a825d4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 2 Oct 2017 15:42:15 -0400 Subject: [PATCH 12/66] Merge master --- arsse.php | 7 +- bootstrap.php | 10 --- build.xml | 1 - lib/Arsse.php | 2 + lib/CLI.php | 2 +- lib/Database.php | 16 +++-- lib/Feed.php | 75 ++++++++++++++-------- lib/REST/NextCloudNews/V1_2.php | 4 +- lib/REST/TinyTinyRSS/API.php | 2 +- tests/Feed/TestFeed.php | 5 +- tests/REST/NextCloudNews/TestNCNV1_2.php | 4 +- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 2 +- tests/bootstrap.php | 8 +++ tests/lib/Database/SeriesSubscription.php | 26 ++++++-- tests/phpunit.xml | 2 +- tests/server.php | 2 +- 16 files changed, 109 insertions(+), 59 deletions(-) delete mode 100644 bootstrap.php create mode 100644 tests/bootstrap.php diff --git a/arsse.php b/arsse.php index 8634bf5..c1fd508 100644 --- a/arsse.php +++ b/arsse.php @@ -1,7 +1,12 @@ - diff --git a/lib/Arsse.php b/lib/Arsse.php index 2475ed6..a453bd7 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { + const VERSION = "0.1.1"; + /** @var Lang */ public static $lang; /** @var Conf */ diff --git a/lib/CLI.php b/lib/CLI.php index 971197f..36aa8db 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -27,7 +27,7 @@ USAGE_TEXT; $this->args = \Docopt::handle($this->usage(), [ 'argv' => $argv, 'help' => true, - 'version' => VERSION, + 'version' => Arsse::VERSION, ]); } diff --git a/lib/Database.php b/lib/Database.php index 5a90b19..9ed18af 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -464,13 +464,19 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } // check to see if the feed exists - $feedID = $this->db->prepare("SELECT id from arsse_feeds where url is ? and username is ? and password is ?", "str", "str", "str")->run($url, $fetchUser, $fetchPassword)->getValue(); + $check = $this->db->prepare("SELECT id from arsse_feeds where url is ? and username is ? and password is ?", "str", "str", "str"); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + if ($discover && is_null($feedID)) { + // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL + $url = Feed::discover($url, $fetchUser, $fetchPassword); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + } if (is_null($feedID)) { - // if the feed doesn't exist add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible + // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); try { // perform an initial update on the newly added feed - $this->feedUpdate($feedID, true, $discover); + $this->feedUpdate($feedID, true); } catch (\Throwable $e) { // if the update fails, delete the feed we just added $this->db->prepare('DELETE from arsse_feeds where id is ?', 'int')->run($feedID); @@ -601,7 +607,7 @@ class Database { return array_column($feeds, 'id'); } - public function feedUpdate($feedID, bool $throwError = false, bool $discover = false): bool { + public function feedUpdate($feedID, bool $throwError = false): bool { $tr = $this->db->begin(); // check to make sure the feed exists if (!ValueInfo::id($feedID)) { @@ -617,7 +623,7 @@ class Database { // here. When an exception is thrown it should update the database with the // error instead of failing; if other exceptions are thrown, we should simply roll back try { - $feed = new Feed((int) $feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape, $discover); + $feed = new Feed((int) $feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape); if (!$feed->modified) { // if the feed hasn't changed, just compute the next fetch time and record it $this->db->prepare("UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id is ?", 'datetime', 'int')->run($feed->nextFetch, $feedID); diff --git a/lib/Feed.php b/lib/Feed.php index ee6114c..a390659 100644 --- a/lib/Feed.php +++ b/lib/Feed.php @@ -5,6 +5,7 @@ namespace JKingWeb\Arsse; use JKingWeb\Arsse\Misc\Date; use PicoFeed\PicoFeedException; use PicoFeed\Config\Config; +use PicoFeed\Client\Client; use PicoFeed\Reader\Reader; use PicoFeed\Reader\Favicon; use PicoFeed\Scraper\Scraper; @@ -12,8 +13,6 @@ use PicoFeed\Scraper\Scraper; class Feed { public $data = null; public $favicon; - public $parser; - public $reader; public $resource; public $modified = false; public $lastModified; @@ -21,22 +20,30 @@ class Feed { public $newItems = []; public $changedItems = []; - public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false, bool $discover = false) { - // set the configuration - $userAgent = Arsse::$conf->fetchUserAgentString ?? sprintf('Arsse/%s (%s %s; %s; https://code.jkingweb.ca/jking/arsse) PicoFeed (https://github.com/fguillot/picoFeed)', - VERSION, // Arsse version - php_uname('s'), // OS - php_uname('r'), // OS version - php_uname('m') // platform architecture - ); - $this->config = new Config; - $this->config->setMaxBodySize(Arsse::$conf->fetchSizeLimit); - $this->config->setClientTimeout(Arsse::$conf->fetchTimeout); - $this->config->setGrabberTimeout(Arsse::$conf->fetchTimeout); - $this->config->setClientUserAgent($userAgent); - $this->config->setGrabberUserAgent($userAgent); + public static function discover(string $url, string $username = '', string $password = ''): string { + // fetch the candidate feed + $f = self::download($url, "", "", $username, $password); + if ($f->reader->detectFormat($f->getContent())) { + // if the prospective URL is a feed, use it + $out = $url; + } else { + $links = $f->reader->find($f->getUrl(), $f->getContent()); + if (!$links) { + // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + libxml_use_internal_errors(false); + throw new Feed\Exception($url, new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription')); + } else { + $out = $links[0]; + } + } + // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + libxml_use_internal_errors(false); + return $out; + } + + public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) { // fetch the feed - $this->download($url, $lastModified, $etag, $username, $password, $discover); + $this->resource = self::download($url, $lastModified, $etag, $username, $password); // format the HTTP Last-Modified date returned $lastMod = $this->resource->getLastModified(); if (strlen($lastMod)) { @@ -65,26 +72,40 @@ class Feed { $this->nextFetch = $this->computeNextFetch(); } - protected function download(string $url, string $lastModified, string $etag, string $username, string $password, bool $discover): bool { - $action = $discover ? "discover" : "download"; + protected static function configure(): Config { + $userAgent = Arsse::$conf->fetchUserAgentString ?? sprintf('Arsse/%s (%s %s; %s; https://thearsse.com/) PicoFeed (https://github.com/miniflux/picoFeed)', + Arsse::VERSION, // Arsse version + php_uname('s'), // OS + php_uname('r'), // OS version + php_uname('m') // platform architecture + ); + $config = new Config; + $config->setMaxBodySize(Arsse::$conf->fetchSizeLimit); + $config->setClientTimeout(Arsse::$conf->fetchTimeout); + $config->setGrabberTimeout(Arsse::$conf->fetchTimeout); + $config->setClientUserAgent($userAgent); + $config->setGrabberUserAgent($userAgent); + return $config; + } + + protected static function download(string $url, string $lastModified, string $etag, string $username, string $password): Client { try { - $this->reader = new Reader($this->config); - $this->resource = $this->reader->$action($url, $lastModified, $etag, $username, $password); + $reader = new Reader(self::configure()); + $client = $reader->download($url, $lastModified, $etag, $username, $password); + $client->reader = $reader; + return $client; } catch (PicoFeedException $e) { throw new Feed\Exception($url, $e); } - return true; } protected function parse(): bool { try { - $this->parser = $this->reader->getParser( + $feed = $this->resource->reader->getParser( $this->resource->getUrl(), $this->resource->getContent(), $this->resource->getEncoding() - ); - $feed = $this->parser->execute(); - + )->execute(); // Grab the favicon for the feed; returns an empty string if it cannot find one. // Some feeds might use a different domain (eg: feedburner), so the site url is // used instead of the feed's url. @@ -388,7 +409,7 @@ class Feed { } protected function scrape(): bool { - $scraper = new Scraper($this->config); + $scraper = new Scraper(self::configure()); foreach (array_merge($this->newItems, $this->changedItems) as $item) { $scraper->setUrl($item->url); $scraper->execute(); diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 8ece274..bab902e 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -691,14 +691,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { protected function serverVersion(array $url, array $data): Response { return new Response(200, [ 'version' => self::VERSION, - 'arsse_version' => \JKingWeb\Arsse\VERSION, + 'arsse_version' => Arsse::VERSION, ]); } protected function serverStatus(array $url, array $data): Response { return new Response(200, [ 'version' => self::VERSION, - 'arsse_version' => \JKingWeb\Arsse\VERSION, + 'arsse_version' => Arsse::VERSION, 'warnings' => [ 'improperlyConfiguredCron' => !Service::hasCheckedIn(), ] diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 0db6601..d9ad532 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -118,7 +118,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetVersion(array $data): array { return [ 'version' => self::VERSION, - 'arsse_version' => \JKingWeb\Arsse\VERSION, + 'arsse_version' => Arsse::VERSION, ]; } diff --git a/tests/Feed/TestFeed.php b/tests/Feed/TestFeed.php index 8957ba8..0ef1eb4 100644 --- a/tests/Feed/TestFeed.php +++ b/tests/Feed/TestFeed.php @@ -134,12 +134,13 @@ class TestFeed extends Test\AbstractTest { } public function testDiscoverAFeedSuccessfully() { - $this->assertInstanceOf(Feed::class, new Feed(null, $this->base."Discovery/Valid", "", "", "", "", false, true)); + $this->assertSame($this->base."Discovery/Feed", Feed::discover($this->base."Discovery/Valid")); + $this->assertSame($this->base."Discovery/Feed", Feed::discover($this->base."Discovery/Feed")); } public function testDiscoverAFeedUnsuccessfully() { $this->assertException("subscriptionNotFound", "Feed"); - new Feed(null, $this->base."Discovery/Invalid", "", "", "", "", false, true); + Feed::discover($this->base."Discovery/Invalid"); } public function testParseEntityExpansionAttack() { diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 713267b..0d8e08e 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -458,7 +458,7 @@ class TestNCNV1_2 extends Test\AbstractTest { public function testRetrieveServerVersion() { $exp = new Response(200, [ - 'arsse_version' => \JKingWeb\Arsse\VERSION, + 'arsse_version' => Arsse::VERSION, 'version' => REST\NextCloudNews\V1_2::VERSION, ]); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/version"))); @@ -842,7 +842,7 @@ class TestNCNV1_2 extends Test\AbstractTest { Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql")); $arr1 = $arr2 = [ 'version' => REST\NextCloudNews\V1_2::VERSION, - 'arsse_version' => VERSION, + 'arsse_version' => Arsse::VERSION, 'warnings' => [ 'improperlyConfiguredCron' => false, ] diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 37aa594..7e35c9e 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -235,7 +235,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ]; $exp = $this->respGood([ 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, - 'arsse_version' => \JKingWeb\Arsse\VERSION, + 'arsse_version' => Arsse::VERSION, ]); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c74e551 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,8 @@ +nextID("arsse_feeds"); $subID = $this->nextID("arsse_subscriptions"); Phake::when(Arsse::$db)->feedUpdate->thenReturn(true); - $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url)); + $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", false)); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); - Phake::verify(Arsse::$db)->feedUpdate($feedID, true, true); + Phake::verify(Arsse::$db)->feedUpdate($feedID, true); $state = $this->primeExpectations($this->data, [ 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], @@ -145,15 +145,33 @@ trait SeriesSubscription { $this->compareExpectations($state); } + public function testAddASubscriptionToANewFeedViaDiscovery() { + $url = "http://localhost:8000/Feed/Discovery/Valid"; + $discovered = "http://localhost:8000/Feed/Discovery/Feed"; + $feedID = $this->nextID("arsse_feeds"); + $subID = $this->nextID("arsse_subscriptions"); + Phake::when(Arsse::$db)->feedUpdate->thenReturn(true); + $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", true)); + Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); + Phake::verify(Arsse::$db)->feedUpdate($feedID, true); + $state = $this->primeExpectations($this->data, [ + 'arsse_feeds' => ['id','url','username','password'], + 'arsse_subscriptions' => ['id','owner','feed'], + ]); + $state['arsse_feeds']['rows'][] = [$feedID,$discovered,"",""]; + $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; + $this->compareExpectations($state); + } + public function testAddASubscriptionToAnInvalidFeed() { $url = "http://example.org/feed1"; $feedID = $this->nextID("arsse_feeds"); Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException($url, new \PicoFeed\Client\InvalidUrlException())); try { - Arsse::$db->subscriptionAdd($this->user, $url); + Arsse::$db->subscriptionAdd($this->user, $url, "", "", false); } catch (FeedException $e) { Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); - Phake::verify(Arsse::$db)->feedUpdate($feedID, true, true); + Phake::verify(Arsse::$db)->feedUpdate($feedID, true); $state = $this->primeExpectations($this->data, [ 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], diff --git a/tests/phpunit.xml b/tests/phpunit.xml index d1e8ea0..6d486bc 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -1,7 +1,7 @@ Date: Tue, 3 Oct 2017 10:43:09 -0400 Subject: [PATCH 13/66] Implement TTRSS feed subscription; fixes #92 --- lib/Database.php | 2 +- lib/REST/TinyTinyRSS/API.php | 85 ++++++++++++++++++++- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 86 +++++++++++++++++++++- 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 9ed18af..a7fea0e 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -496,7 +496,7 @@ class Database { // create a complex query $q = new Query( "SELECT - arsse_subscriptions.id, + arsse_subscriptions.id as id, url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d9ad532..43c8f2c 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\TinyTinyRSS; +use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; @@ -164,7 +165,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return (int) $folder['id']; } } - return false; + return false; // @codeCoverageIgnore case 10235: // parent folder does not exist; this returns false as an ID return false; default: // other errors related to input @@ -226,6 +227,88 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return null; } + 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 (!isset($data['feed_url']) || !(ValueInfo::str($data['feed_url']) & ValueInfo::VALID)) { + // if the feed URL is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + // normalize input data + if ( + (isset($data['category_id']) && !ValueInfo::id($data['category_id'], true)) || + (isset($data['login']) && !(ValueInfo::str($data['login']) & ValueInfo::VALID)) || + (isset($data['password']) && !(ValueInfo::str($data['password']) & ValueInfo::VALID)) + ) { + // if the category is not a valid ID or the feed username or password are not convertible to strings, also throw an error + throw new Exception("INCORRECT_USAGE"); + } + $url = (string) $data['feed_url']; + $folder = isset($data['category_id']) ? (int) $data['category_id'] : null; + $fetchUser = isset($data['login']) ? (string) $data['login'] : ""; + $fetchPassword = isset($data['password']) ? (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 { if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { // if the feed is invalid, throw an error diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 7e35c9e..3b4b152 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -11,7 +11,8 @@ use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use Phake; -/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API */ +/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API + * @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */ class TestTinyTinyAPI extends Test\AbstractTest { protected $h; protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler @@ -253,6 +254,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $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' => " "], @@ -260,6 +262,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $db = [ ['name' => "Software", 'parent' => null], ['name' => "Hardware", 'parent' => 1], + ['name' => "Hardware", 'parent' => 2112], ]; $out = [ ['id' => 2, 'name' => "Software", 'parent' => null], @@ -272,6 +275,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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")); @@ -287,11 +291,14 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); // add some invalid folders $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); } public function testRemoveACategory() { @@ -399,6 +406,81 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Failed test $a"); + } + $exp = $this->respErr("INCORRECT_USAGE"); + for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) { + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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], From 03d5d554a9f21504e9e8103f316b6c494c5713c3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 3 Oct 2017 12:43:46 -0400 Subject: [PATCH 14/66] Imple TTRSS functions getUnread and getConfig - Fixes #78 - Fixes #85 --- lib/Database.php | 18 +++++++++++++++ lib/REST/TinyTinyRSS/API.php | 18 +++++++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 26 ++++++++++++++++++++++ tests/lib/Database/SeriesSubscription.php | 16 +++++++++++++ 4 files changed, 78 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index a7fea0e..ca98b3d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -524,6 +524,24 @@ class Database { 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())->getValue(); + } + public function subscriptionRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 43c8f2c..6e1a71d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -361,4 +361,22 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return null; } + + 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' => $out]; + } + + 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), + ]; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 3b4b152..467e359 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -583,4 +583,30 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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' => 2112 + 42 + 47]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($this->respGood($exp[0]), $this->h->dispatch(new Request("POST", "", json_encode($in)))); + $this->assertEquals($this->respGood($exp[1]), $this->h->dispatch(new Request("POST", "", json_encode($in)))); + } } diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index a40e509..4a1c841 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -282,6 +282,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); From 69b34a4e5a1601852df2442234ae16117cdd79ac Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 3 Oct 2017 16:14:37 -0400 Subject: [PATCH 15/66] Implement TTRSS feed updating; fixes #86 --- lib/Database.php | 2 +- lib/REST/TinyTinyRSS/API.php | 15 ++++++++++++++- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ca98b3d..47c74de 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -497,7 +497,7 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, 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 diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 6e1a71d..c50703e 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -342,7 +342,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opRenameFeed(array $data) { if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['caption'])) { - // if the feed is invalid, throw an error + // if the feed is invalid or there is no caption, throw an error throw new Exception("INCORRECT_USAGE"); } $info = ValueInfo::str($data['caption']); @@ -379,4 +379,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'num_feeds' => Arsse::$db->subscriptionCount(Arsse::$user->id), ]; } + + 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, (int) $data['feed_id'])['feed']); + } catch(ExceptionInput $e) { + throw new Exception("FEED_NOT_FOUND"); + } + return ['status' => "OK"]; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 467e359..b96bc01 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -609,4 +609,24 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($this->respGood($exp[0]), $this->h->dispatch(new Request("POST", "", json_encode($in)))); $this->assertEquals($this->respGood($exp[1]), $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + Phake::verify(Arsse::$db)->feedUpdate(11); + $exp = $this->respErr("FEED_NOT_FOUND"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + } } From 26f6922b25cc22391daaaa2691a3c6c7aaeeb200 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 5 Oct 2017 17:42:12 -0400 Subject: [PATCH 16/66] Partially implement labels - Backend functions for adding, listing, removing, and editing (renaming) labels currently implemented - TTRSS functions for adding (fixes #96), removing (fixes #97), and renaming (fixes #98) labels currently implemented --- lib/Database.php | 142 ++++- lib/REST.php | 7 +- lib/REST/TinyTinyRSS/API.php | 58 ++ sql/SQLite3/1.sql | 7 +- .../Database/TestDatabaseLabelSQLite3.php | 10 + tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 119 ++++ tests/lib/Database/SeriesLabel.php | 523 ++++++++++++++++++ tests/lib/Database/SeriesSubscription.php | 3 + tests/lib/Database/SeriesUser.php | 3 + tests/phpunit.xml | 1 + 10 files changed, 859 insertions(+), 14 deletions(-) create mode 100644 tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php create mode 100644 tests/lib/Database/SeriesLabel.php diff --git a/lib/Database.php b/lib/Database.php index 47c74de..e50312c 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -204,6 +204,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); } @@ -314,7 +318,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) { @@ -328,7 +332,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) { @@ -362,7 +366,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 = [ @@ -547,7 +551,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) { @@ -561,7 +565,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"]); } // disable authorization checks for the list call Arsse::$user->authorizationEnabled(false); @@ -604,14 +608,18 @@ 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; } 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) { @@ -1051,7 +1059,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 @@ -1072,7 +1080,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 @@ -1112,4 +1120,120 @@ 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 owner is ? and label is arsse_labels.id) as articles + FROM arsse_labels where owner is ? and articles >= ? + ", "str", "str", "int" + )->run($user, $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]); + } + 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); + } + $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]); + } + 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $out = $this->db->prepare( + "SELECT + id,name, + (select count(*) from arsse_label_members where owner is ? and label is arsse_labels.id) as articles + FROM arsse_labels where $field is ? and owner is ? + ", "str", $type, "str" + )->run($user, $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]); + } + 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); + } + 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; + } + + 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/REST.php b/lib/REST.php index 15bfac7..c340e37 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -26,9 +26,14 @@ class REST { // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Feedbin v2 https://github.com/feedbin/feedbin-api // Fever https://feedafever.com/api - // NewsBlur http://www.newsblur.com/api + // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown // CommaFeed https://www.commafeed.com/api/ + // 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/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index c50703e..2fed94a 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -20,6 +20,8 @@ Protocol difference so far: - TT-RSS accepts whitespace-only names; we do not - TT-RSS allows two folders to share the same name under the same parent; we do not - Session lifetime is much shorter by default (does TT-RSS even expire sessions?) + - Categories and feeds will always be sorted alphabetically (the protocol does not allow for clients to re-order) + - Label IDs decrease from -11 instead of from -1025 */ @@ -392,4 +394,60 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return ['status' => "OK"]; } + + protected function labelIn($id): int { + if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > -11) { + throw new Exception("INCORRECT_USAGE"); + } + return (abs($id) - 10); + } + + protected function labelOut(int $id): int { + return ($id * -1 - 10); + } + + public function opAddLabel(array $data) { + $in = [ + 'name' => isset($data['caption']) ? $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(isset($data['label_id']) ? $data['label_id'] : 0); + 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(isset($data['label_id']) ? $data['label_id'] : 0); + $name = isset($data['caption']) ? $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; + } } diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 8a50ed1..50e7567 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -1,8 +1,8 @@ -- Sessions for Tiny Tiny RSS (and possibly others) create table arsse_sessions ( id text primary key, -- UUID of session - created datetime not null default CURRENT_TIMESTAMP, -- Session start timestamp - expires datetime not null, -- Time at which session is no longer valid + 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; @@ -11,8 +11,7 @@ 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 - foreground text, -- foreground (text) colour in hexdecimal RGB - background text, -- background colour in hexadecimal RGB + modified text not null default CURRENT_TIMESTAMP, -- time at which the label was last modified unique(owner,name) ); diff --git a/tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php b/tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php new file mode 100644 index 0000000..815bd49 --- /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/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index b96bc01..0294b1d 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -629,4 +629,123 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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(-12); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $exp = $this->respGood(-13); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // attempt to add the two labels again + $exp = $this->respGood(-12); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $exp = $this->respGood(-13); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + } + + public function testRemoveALabel() { + $in = [ + ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42], + ['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, 32)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a label + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // try deleting it again (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // delete a label which does not exist (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // delete some invalid labels (causes an error) + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 32); + Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 2102); + } + + public function testRenameALabel() { + $in = [ + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'caption' => "Eek"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => "Eek"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => ""], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => " "], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1, 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 32, ['name' => "Ook"]], + [Arsse::$user->id, 2102, ['name' => "Eek"]], + [Arsse::$user->id, 32, ['name' => "Eek"]], + [Arsse::$user->id, 32, ['name' => ""]], + [Arsse::$user->id, 32, ['name' => " "]], + [Arsse::$user->id, 32, ['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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // rename a label which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // rename a label causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } } diff --git a/tests/lib/Database/SeriesLabel.php b/tests/lib/Database/SeriesLabel.php new file mode 100644 index 0000000..9e4f0a4 --- /dev/null +++ b/tests/lib/Database/SeriesLabel.php @@ -0,0 +1,523 @@ + [ + '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"], + ], + ] + ]; + protected $matches = [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', + '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, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + '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", + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', + 'published_date' => '2000-01-03 00:00:00', + 'edited_date' => '2000-01-03 00:00:03', + 'modified_date' => '2000-01-03 03:00:00', + 'unread' => 1, + 'starred' => 1, + 'edition' => 203, + 'subscription' => 9, + 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', + 'media_url' => "http://example.com/video", + 'media_type' => "video/webm", + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', + 'published_date' => '2000-01-04 00:00:00', + 'edited_date' => '2000-01-04 00:00:04', + 'modified_date' => '2000-01-04 04:00:00', + 'unread' => 0, + 'starred' => 1, + 'edition' => 204, + 'subscription' => 9, + 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', + 'media_url' => "http://example.com/image", + 'media_type' => "image/svg+xml", + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', + 'published_date' => '2000-01-05 00:00:00', + 'edited_date' => '2000-01-05 00:00:05', + 'modified_date' => '2000-01-05 05:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 305, + 'subscription' => 10, + 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', + 'media_url' => "http://example.com/audio", + 'media_type' => "audio/ogg", + ], + ]; + + public function setUpSeries() { + $this->checkTables = ['arsse_labels' => ["id","owner","name"],]; + $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->checkTables); + $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' => 0], + ['id' => 1, 'name' => "Interesting", 'articles' => 0], + ]; + $this->assertSame($exp, Arsse::$db->labelList("john.doe@example.com")->getAll()); + $exp = [ + ['id' => 3, 'name' => "Boring", 'articles' => 0], + ]; + $this->assertSame($exp, Arsse::$db->labelList("jane.doe@example.com")->getAll()); + $exp = []; + $this->assertSame($exp, Arsse::$db->labelList("admin@example.net")->getAll()); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList"); + Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "labelList"); + Phake::verify(Arsse::$user)->authorize("admin@example.net", "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->checkTables); + 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->checkTables); + 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' => 0, + ]; + $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->checkTables); + $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->checkTables); + $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"]); + } +} diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 4a1c841..3a5a159 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -333,6 +333,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() { diff --git a/tests/lib/Database/SeriesUser.php b/tests/lib/Database/SeriesUser.php index e4a72c5..e0f0223 100644 --- a/tests/lib/Database/SeriesUser.php +++ b/tests/lib/Database/SeriesUser.php @@ -209,6 +209,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/phpunit.xml b/tests/phpunit.xml index 6d486bc..f12fe40 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -62,6 +62,7 @@ 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
From 7a2de95c7005dfe396e3034c92d5ca1c8686552a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 6 Oct 2017 20:26:22 -0400 Subject: [PATCH 17/66] Consolidate article context handling into articleQuery function Also consolidated article star counting into a generic articleCount function which accepts a context. This may lead to slight efficiency losses in either listing or marking (and more significant ones in counting starred), but the advantages of centralized context handling are significant with the future addition of labels and the need to count articles under various future contexts in TTRSS. --- lib/Database.php | 230 +++++++++++------------ lib/Misc/Query.php | 31 ++- lib/REST/NextCloudNews/V1_2.php | 2 +- tests/REST/NextCloudNews/TestNCNV1_2.php | 2 +- tests/lib/Database/SeriesArticle.php | 10 +- 5 files changed, 144 insertions(+), 131 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index e50312c..9bc975e 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -301,16 +301,22 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } // 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 + $parent = $this->folderValidateId($user, $parent)['id']; + $q = new Query( + "SELECT + id,name,parent, + (select count(*) from arsse_folders as parents where parents.parent is arsse_folders.id) as children + 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 { @@ -794,35 +800,25 @@ 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, max( - modified, + 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 (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); @@ -831,17 +827,56 @@ class Database { // 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"); } 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) > 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"); } // filter based on edition offset if ($context->oldestEdition()) { @@ -864,6 +899,29 @@ class Database { if ($context->starred()) { $q->setWhere("starred is ?", "bool", $context->starred); } + // return the query + return $q; + } + + 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]); + } + $context = $context ?? new Context; + $columns = [ + "arsse_articles.url as url", + "title", + "author", + "content", + "guid", + "published as published_date", + "edited as edited_date", + "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", + "arsse_enclosures.url as media_url", + "arsse_enclosures.type as media_type", + ]; + $q = $this->articleQuery($user, $context, $columns); + $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()); } @@ -916,94 +974,15 @@ class Database { // 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); + $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)", "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"); - } - if ($context->edition()) { - // if an edition is specified, filter for its previously identified article - $q->setWhere("arsse_articles.id is ?", "int", $edition['article']); - } 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) > 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"); - } - // 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->pushCTE("target_articles"); $q->setBody($query); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } @@ -1012,11 +991,15 @@ class Database { return (bool) $out; } - public function articleStarredCount(string $user): int { + public function articleCount(string $user, Context $context = null): int { 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(); + $context = $context ?? new Context; + $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 articleCleanup(): bool { @@ -1105,9 +1088,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 @@ -1141,10 +1122,15 @@ class Database { return $this->db->prepare( "SELECT id,name, - (select count(*) from arsse_label_members where owner is ? and label is arsse_labels.id) as articles + (select count(*) from arsse_label_members join arsse_subscriptions on arsse_subscriptions.owner is arsse_labels.owner where label is arsse_labels.id) 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 + join arsse_subscriptions on arsse_subscriptions.owner is arsse_labels.owner + where label is arsse_labels.id and read is 1 + ) as read FROM arsse_labels where owner is ? and articles >= ? - ", "str", "str", "int" - )->run($user, $user, !$includeEmpty); + ", "str", "int" + )->run($user, !$includeEmpty); } public function labelRemove(string $user, $id, bool $byName = false): bool { diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index b480d3b..24445d5 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -10,6 +10,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 @@ -43,6 +46,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)) { @@ -77,6 +89,9 @@ class Query { $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qJoin = []; + $this->tJoin = []; + $this->vJoin = []; $this->order = []; $this->setLimit(0, 0); if (strlen($join)) { @@ -101,11 +116,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 { @@ -132,6 +155,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/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index bab902e..09f8546 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -395,7 +395,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->articleCount(Arsse::$user->id, (new Context)->starred(true)); $newest = Arsse::$db->editionLatest(Arsse::$user->id); if ($newest) { $out['newestItemId'] = $newest; diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 0d8e08e..d3fe141 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -475,7 +475,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)->articleCount(Arsse::$user->id, (new Context)->starred(true))->thenReturn(0)->thenReturn(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"))); diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 0487661..3f363fb 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -731,16 +731,16 @@ trait SeriesArticle { } 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")); + $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); + $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.org", (new Context)->starred(true))); + $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.net", (new Context)->starred(true))); + $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true))); } public function testCountStarredArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); - Arsse::$db->articleStarredCount($this->user); + Arsse::$db->articleCount($this->user, (new Context)->starred(true)); } public function testFetchLatestEdition() { From 0e6eed56991162aac08578fa1886c594656bccd8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 7 Oct 2017 12:44:53 -0400 Subject: [PATCH 18/66] Add boolean sanitizer to ValueInfo --- lib/Misc/ValueInfo.php | 12 +++++ lib/REST/AbstractHandler.php | 2 +- tests/Misc/TestValueInfo.php | 93 ++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 040243d..c382836 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -89,4 +89,16 @@ class ValueInfo { return true; } } + + public static function bool($value, bool $default = null) { + if (is_null($value) || ValueInfo::str($value) & ValueInfo::WHITE) { + return $default; + } + $out = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + if (is_null($out) && (ValueInfo::int($value) & ValueInfo::VALID)) { + $out = abs((int) filter_var($value, \FILTER_VALIDATE_FLOAT)); + return ($out < 2) ? (bool) $out : $default; + } + return !is_null($out) ? $out : $default; + } } diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index 8c0988d..81c3a68 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -59,7 +59,7 @@ abstract class AbstractHandler implements Handler { } break; case "bool": - $test = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + $test = ValueInfo::bool($value); if (!is_null($test)) { $out[$key] = $test; } diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index 1a75099..1291b47 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -52,6 +52,12 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", I::VALID | I::ZERO], [false, 0], [true, 0], + ["on", 0], + ["off", 0], + ["yes", 0], + ["no", 0], + ["true", 0], + ["false", 0], [INF, 0], [-INF, 0], [NAN, 0], @@ -116,6 +122,12 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", I::VALID], [false, 0], [true, 0], + ["on", I::VALID], + ["off", I::VALID], + ["yes", I::VALID], + ["no", I::VALID], + ["true", I::VALID], + ["false", I::VALID], [INF, 0], [-INF, 0], [NAN, 0], @@ -181,6 +193,12 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", false, true], [false, false, false], [true, false, false], + ["on", false, false], + ["off", false, false], + ["yes", false, false], + ["no", false, false], + ["true", false, false], + ["false", false, false], [INF, false, false], [-INF, false, false], [NAN, false, false], @@ -201,4 +219,79 @@ class TestValueInfo extends Test\AbstractTest { $this->assertSame($expNull, I::id($value, true), "Null test failed for value: ".var_export($value, true)); } } + + public function testValidateBoolean() { + $tests = [ + [null, null], + ["", false], + [1, true], + [PHP_INT_MAX, null], + [1.0, true], + ["1.0", true], + ["001.0", true], + ["1.0e2", null], + ["1", true], + ["001", true], + ["1e2", null], + ["+1.0", true], + ["+001.0", true], + ["+1.0e2", null], + ["+1", true], + ["+001", true], + ["+1e2", null], + [0, false], + ["0", false], + ["000", false], + [0.0, false], + ["0.0", false], + ["000.000", false], + ["+0", false], + ["+000", false], + ["+0.0", false], + ["+000.000", false], + [-1, true], + [-1.0, true], + ["-1.0", true], + ["-001.0", true], + ["-1.0e2", null], + ["-1", true], + ["-001", true], + ["-1e2", null], + [-0, false], + ["-0", false], + ["-000", false], + [-0.0, false], + ["-0.0", false], + ["-000.000", false], + [false, false], + [true, true], + ["on", true], + ["off", false], + ["yes", true], + ["no", false], + ["true", true], + ["false", false], + [INF, null], + [-INF, null], + [NAN, null], + [[], null], + ["some string", null], + [" ", null], + [new \StdClass, null], + [new StrClass(""), false], + [new StrClass("1"), true], + [new StrClass("0"), false], + [new StrClass("-1"), true], + [new StrClass("Msg"), null], + [new StrClass(" "), null], + ]; + foreach ($tests as $test) { + list($value, $exp) = $test; + $this->assertSame($exp, I::bool($value), "Null Test failed for value: ".var_export($value, true)); + if (is_null($exp)) { + $this->assertTrue(I::bool($value, true), "True Test failed for value: ".var_export($value, true)); + $this->assertFalse(I::bool($value, false), "False Test failed for value: ".var_export($value, true)); + } + } + } } From c9c6891567c00b90dd620c3fbf0ea9e21f440903 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 7 Oct 2017 12:46:05 -0400 Subject: [PATCH 19/66] Implement TTRSS getCategories; fixes #81 --- lib/REST/TinyTinyRSS/API.php | 75 +++++++ locale/en.php | 4 + tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 226 +++++++++------------ tests/lib/Database/SeriesFolder.php | 34 ++-- tests/lib/Database/SeriesLabel.php | 6 +- 5 files changed, 197 insertions(+), 148 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 2fed94a..3efef8d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -6,6 +6,7 @@ use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; @@ -147,6 +148,80 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return ['status' => true]; } + public function opGetCategories(array $data): array { + // normalize input + $all = isset($data['include_empty']) ? ValueInfo::bool($data['include_empty'], false) : false; + $read = !(isset($data['unread_only']) ? ValueInfo::bool($data['unread_only'], false) : false); + $deep = !(isset($data['enable_nested']) ? ValueInfo::bool($data['enable_nested'], false) : false); + $user = Arsse::$user->id; + // for each category, add the ID to a lookup table, set the number of unread and feeds to zero, and assign an increasing order index + $cats = Arsse::$db->folderList($user, null, $deep)->getAll(); + $map = []; + for ($a = 0; $a < sizeof($cats); $a++) { + $map[$cats[$a]['id']] = $a; + $cats[$a]['unread'] = 0; + $cats[$a]['feeds'] = 0; + $cats[$a]['order'] = $a + 1; + } + // add the "Uncategorized", "Special", and "Labels" virtual categories to the list + $map[0] = sizeof($cats); + $cats[] = ['id' => 0, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'children' => 0, 'unread' => 0, 'feeds' => 0]; + $map[-1] = sizeof($cats); + $cats[] = ['id' => -1, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), 'children' => 0, 'unread' => 0, 'feeds' => 6]; + $map[-2] = sizeof($cats); + $cats[] = ['id' => -2, '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']; + $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[-2]; + 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[-1]; + $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->starred(true)); // 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++) { + $out[] = $this->fieldMapNames($cats[$a], [ + 'id' => "id", + 'title' => "name", + 'unread' => "unread", + 'order_id' => "order", + ]); + } + return $out; + } + public function opAddCategory(array $data) { $in = [ 'name' => isset($data['caption']) ? $data['caption'] : "", diff --git a/locale/en.php b/locale/en.php index 1600c7b..0539a0f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -1,5 +1,9 @@ 'Uncategorized', + 'API.TTRSS.Category.Special' => 'Special', + 'API.TTRSS.Category.Labels' => 'Labels', + 'Driver.Db.SQLite3.Name' => 'SQLite 3', 'Driver.Service.Curl.Name' => 'HTTP (curl)', 'Driver.Service.Internal.Name' => 'Internal', diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 0294b1d..ad383b7 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -15,134 +15,6 @@ use Phake; * @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */ class TestTinyTinyAPI extends Test\AbstractTest { protected $h; - protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler - 'db' => [ - [ - 'id' => 2112, - 'url' => 'http://example.com/news.atom', - 'favicon' => 'http://example.com/favicon.png', - 'source' => 'http://example.com/', - 'folder' => null, - 'top_folder' => null, - 'pinned' => 0, - 'err_count' => 0, - 'err_msg' => '', - 'order_type' => 0, - 'added' => '2017-05-20 13:35:54', - 'title' => 'First example feed', - 'unread' => 50048, - ], - [ - 'id' => 42, - 'url' => 'http://example.org/news.atom', - 'favicon' => 'http://example.org/favicon.png', - 'source' => 'http://example.org/', - 'folder' => 12, - 'top_folder' => 8, - 'pinned' => 1, - 'err_count' => 0, - 'err_msg' => '', - 'order_type' => 2, - 'added' => '2017-05-20 13:35:54', - 'title' => 'Second example feed', - 'unread' => 23, - ], - ], - ]; - protected $articles = [ - 'db' => [ - [ - 'id' => 101, - 'url' => 'http://example.com/1', - 'title' => 'Article title 1', - 'author' => '', - 'content' => '

Article content 1

', - 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', - '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, - ], - [ - 'id' => 102, - 'url' => 'http://example.com/2', - 'title' => 'Article title 2', - 'author' => '', - '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", - ], - [ - 'id' => 103, - 'url' => 'http://example.com/3', - 'title' => 'Article title 3', - 'author' => '', - 'content' => '

Article content 3

', - 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', - 'published_date' => '2000-01-03 00:00:00', - 'edited_date' => '2000-01-03 00:00:03', - 'modified_date' => '2000-01-03 03:00:00', - 'unread' => 1, - 'starred' => 1, - 'edition' => 203, - 'subscription' => 9, - 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', - 'media_url' => "http://example.com/video", - 'media_type' => "video/webm", - ], - [ - 'id' => 104, - 'url' => 'http://example.com/4', - 'title' => 'Article title 4', - 'author' => '', - 'content' => '

Article content 4

', - 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', - 'published_date' => '2000-01-04 00:00:00', - 'edited_date' => '2000-01-04 00:00:04', - 'modified_date' => '2000-01-04 04:00:00', - 'unread' => 0, - 'starred' => 1, - 'edition' => 204, - 'subscription' => 9, - 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', - 'media_url' => "http://example.com/image", - 'media_type' => "image/svg+xml", - ], - [ - 'id' => 105, - 'url' => 'http://example.com/5', - 'title' => 'Article title 5', - 'author' => '', - 'content' => '

Article content 5

', - 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', - 'published_date' => '2000-01-05 00:00:00', - 'edited_date' => '2000-01-05 00:00:05', - 'modified_date' => '2000-01-05 05:00:00', - 'unread' => 1, - 'starred' => 0, - 'edition' => 305, - 'subscription' => 10, - 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', - 'media_url' => "http://example.com/audio", - 'media_type' => "audio/ogg", - ], - ] - ]; protected function respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -748,4 +620,102 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } + + public function testRetrieveCategoryLists() { + $folders = [ + ['id' => 5, 'parent' => 3, 'children' => 0, 'name' => "Local"], + ['id' => 6, 'parent' => 3, 'children' => 0, 'name' => "National"], + ['id' => 4, 'parent' => null, 'children' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'name' => "Politics"], + ['id' => 2, 'parent' => 1, 'children' => 0, 'name' => "Rocketry"], + ['id' => 1, 'parent' => null, 'children' => 1, 'name' => "Science"], + ]; + $topFolders = [ + ['id' => 4, 'parent' => null, 'children' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'name' => "Politics"], + ['id' => 1, 'parent' => null, 'children' => 1, 'name' => "Science"], + ]; + $subscriptions = [ + ['folder' => null, 'top_folder' => null, 'unread' => 0], + ['folder' => 1, 'top_folder' => 1, 'unread' => 2], + ['folder' => 2, 'top_folder' => 1, 'unread' => 5], + ['folder' => 5, 'top_folder' => 3, 'unread' => 10], + ['folder' => 6, 'top_folder' => 3, 'unread' => 12], + ['folder' => 6, 'top_folder' => 3, 'unread' => 6], + ]; + $labels = [ + ['articles' => 0, 'read' => 0], + ['articles' => 100, 'read' => 94], + ['articles' => 2, 'read' => 0], + ]; + Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($folders)); + Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($topFolders)); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($subscriptions)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($labels)); + Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context + Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->starred(true))->thenReturn(4); + $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], + ]; + $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' => 8], + ], + [ + ['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' => 8], + ], + [ + ['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' => 8], + ], + [ + ['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' => 8], + ], + [ + ['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' => 8], + ], + [ + ['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' => 8], + ], + ]; + for ($a = 0; $a < sizeof($in); $a++) { + $this->assertEquals($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + } + } } diff --git a/tests/lib/Database/SeriesFolder.php b/tests/lib/Database/SeriesFolder.php index 5c0d15b..2258393 100644 --- a/tests/lib/Database/SeriesFolder.php +++ b/tests/lib/Database/SeriesFolder.php @@ -109,16 +109,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"); @@ -126,21 +126,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 index 9e4f0a4..f787ed6 100644 --- a/tests/lib/Database/SeriesLabel.php +++ b/tests/lib/Database/SeriesLabel.php @@ -358,13 +358,13 @@ trait SeriesLabel { ['id' => 2, 'name' => "Fascinating", 'articles' => 0], ['id' => 1, 'name' => "Interesting", 'articles' => 0], ]; - $this->assertSame($exp, Arsse::$db->labelList("john.doe@example.com")->getAll()); + $this->assertResult($exp, Arsse::$db->labelList("john.doe@example.com")); $exp = [ ['id' => 3, 'name' => "Boring", 'articles' => 0], ]; - $this->assertSame($exp, Arsse::$db->labelList("jane.doe@example.com")->getAll()); + $this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com")); $exp = []; - $this->assertSame($exp, Arsse::$db->labelList("admin@example.net")->getAll()); + $this->assertResult($exp, Arsse::$db->labelList("admin@example.net")); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "labelList"); Phake::verify(Arsse::$user)->authorize("admin@example.net", "labelList"); From 20ff08a431d52daaa7c0c1f0b5895f6ce8085d98 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 11 Oct 2017 12:55:50 -0400 Subject: [PATCH 20/66] Implement TTTRSS getCounters operation; fixes #79 --- lib/Database.php | 19 ++- lib/Db/AbstractStatement.php | 1 - lib/REST/NextCloudNews/V1_2.php | 2 +- lib/REST/TinyTinyRSS/API.php | 138 ++++++++++++++----- tests/REST/NextCloudNews/TestNCNV1_2.php | 2 +- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 152 ++++++++++++++------- tests/lib/Database/SeriesArticle.php | 22 ++- 7 files changed, 243 insertions(+), 93 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 9bc975e..7d745a7 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -305,7 +305,8 @@ class Database { $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_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) { @@ -508,6 +509,7 @@ class Database { "SELECT 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 @@ -1002,6 +1004,21 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + 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(*) 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 articleCleanup(): bool { $query = $this->db->prepare( "WITH target_feed(id,subs) as (". diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 3ac5052..5c96f28 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -7,7 +7,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/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 09f8546..46a6a1c 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -395,7 +395,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = $this->feedTranslate($sub); } $out = ['feeds' => $out]; - $out['starredCount'] = Arsse::$db->articleCount(Arsse::$user->id, (new Context)->starred(true)); + $out['starredCount'] = Arsse::$db->articleStarred(Arsse::$user->id)['total']; $newest = Arsse::$db->editionLatest(Arsse::$user->id); if ($newest) { $out['newestItemId'] = $newest; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 3efef8d..b6f10ff 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -17,13 +17,13 @@ use JKingWeb\Arsse\REST\Response; /* Protocol difference so far: - - handling of incorrect Content-Type and/or HTTP method is different + - Handling of incorrect Content-Type and/or HTTP method is different - TT-RSS accepts whitespace-only names; we do not - TT-RSS allows two folders to share the same name under the same parent; we do not - Session lifetime is much shorter by default (does TT-RSS even expire sessions?) - Categories and feeds will always be sorted alphabetically (the protocol does not allow for clients to re-order) - - Label IDs decrease from -11 instead of from -1025 - + - The "Archived" virtual feed is non-functional (the protocol does not allow archiving) + - The "Published" virtual feed is non-functional (this will not be implemented in the near term) */ @@ -31,14 +31,12 @@ Protocol difference so far: class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; + const LABEL_OFFSET = 1024; const FATAL_ERR = [ 'seq' => null, 'status' => 1, 'content' => ['error' => "NOT_LOGGED_IN"], ]; - const OVERRIDE = [ - 'auth' => ["login"], - ]; public function __construct() { } @@ -65,8 +63,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'sid' => null, ], $data); try { - if (!in_array($data['op'], self::OVERRIDE['auth'])) { - // unless otherwise specified, a session identifier is required + if (strtolower((string) $data['op']) != "login") { + // unless logging in, a session identifier is required $this->resumeSession($data['sid']); } $method = "op".ucfirst($data['op']); @@ -148,19 +146,109 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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' => $out]; + } + + 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[0] = sizeof($categories); + $categories[] = ['id' => 0, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'parent' => 0, 'children' => 0, 'counter' => 0]; + $catmap[-2] = sizeof($categories); + $categories[] = ['id' => -2, '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' => $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; + // 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[-2]]['counter'] += $unread; + } + // do a second pass on categories, summing descendant unread counts for ancestors, pruning categories with no unread, and building a final category list + $cats = []; + while ($categories) { + foreach ($categories 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 + $categories[$catmap[$c['parent']]]['counter'] += $c['counter']; + $categories[$catmap[$c['parent']]]['children'] -= 1; + } + if ($c['counter']) { + // if the category's counter is non-zero, add the category to the output list + $cats[] = ['id' => $c['id'], 'kind' => "cat", 'counter' => $c['counter']]; + } + // remove the category from the input list + unset($categories[$catmap[$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' => 0, 'counter' => 0, 'auxcounter' => 0], // Archived articles + ['id' => -1, 'counter' => $starred['unread'], 'auxcounter' => $starred['total']], // Starred articles + ['id' => -2, 'counter' => 0, 'auxcounter' => 0], // Published articles + ['id' => -3, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles + ['id' => -4, 'counter' => $countAll, 'auxcounter' => 0], // All articles + ]; + return array_merge($special, $labels, $feeds, $cats); + } + public function opGetCategories(array $data): array { // normalize input $all = isset($data['include_empty']) ? ValueInfo::bool($data['include_empty'], false) : false; $read = !(isset($data['unread_only']) ? ValueInfo::bool($data['unread_only'], false) : false); $deep = !(isset($data['enable_nested']) ? ValueInfo::bool($data['enable_nested'], false) : false); $user = Arsse::$user->id; - // for each category, add the ID to a lookup table, set the number of unread and feeds to zero, and assign an increasing order index + // 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++) { $map[$cats[$a]['id']] = $a; $cats[$a]['unread'] = 0; - $cats[$a]['feeds'] = 0; $cats[$a]['order'] = $a + 1; } // add the "Uncategorized", "Special", and "Labels" virtual categories to the list @@ -176,7 +264,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // 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']; - $cats[$f]['feeds'] += 1; + 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); @@ -188,7 +278,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // get the unread counts for the special feeds // FIXME: this is pretty inefficient $f = $map[-1]; - $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->starred(true)); // starred + $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) @@ -439,24 +529,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return null; } - 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' => $out]; - } - - 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 opUpdateFeed(array $data): array { if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { // if the feed is invalid, throw an error @@ -471,14 +543,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function labelIn($id): int { - if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > -11) { + if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) { throw new Exception("INCORRECT_USAGE"); } - return (abs($id) - 10); + return (abs($id) - self::LABEL_OFFSET); } protected function labelOut(int $id): int { - return ($id * -1 - 10); + return ($id * -1 - self::LABEL_OFFSET); } public function opAddLabel(array $data) { diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index d3fe141..813ef9d 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -475,7 +475,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)->articleCount(Arsse::$user->id, (new Context)->starred(true))->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"))); diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index ad383b7..77a98f1 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -9,12 +9,43 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; +use JKingWeb\Arsse\REST\TinyTinyRSS\API; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API * @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' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'favicon' => 'http://example.com/6.png'], + ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'favicon' => 'http://example.com/3.png'], + ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'favicon' => null], + ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'favicon' => 'http://example.com/2.png'], + ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'favicon' => ''], + ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'favicon' => 'http://example.com/4.png'], + ]; + protected $labels = [ + ['id' => 5, 'articles' => 0, 'read' => 0], + ['id' => 3, 'articles' => 100, 'read' => 94], + ['id' => 1, 'articles' => 2, 'read' => 0], + ]; + protected $usedLabels = [ + ['id' => 3, 'articles' => 100, 'read' => 94], + ['id' => 1, 'articles' => 2, 'read' => 0], + ]; protected function respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -33,6 +64,19 @@ class TestTinyTinyAPI extends Test\AbstractTest { ]); } + protected function assertResponse(Response $exp, Response $act, string $text = null) { + if ($exp->payload['status']) { + // if the expectation is an error response, do a straight object comparison + $this->assertEquals($exp, $act, $text); + } else { + // otherwise just compare their content + foreach ($act->payload['content'] as $record) { + $this->assertContains($record, $exp->payload['content'], $text); + } + $this->assertCount(sizeof($exp->payload['content']), $act->payload['content'], $text); + } + } + public function setUp() { $this->clearData(); Arsse::$conf = new Conf(); @@ -529,14 +573,14 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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(-12); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); - $exp = $this->respGood(-13); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); // attempt to add the two labels again - $exp = $this->respGood(-12); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); - $exp = $this->respGood(-13); + $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true); @@ -549,14 +593,14 @@ class TestTinyTinyAPI extends Test\AbstractTest { public function testRemoveALabel() { $in = [ - ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42], + ['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, 32)->thenReturn(true)->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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); @@ -571,29 +615,29 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 32); - Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 2102); + 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' => -42, 'caption' => "Ook"], + ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => "Ook"], ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'caption' => "Eek"], - ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => "Eek"], - ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => ""], - ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42, 'caption' => " "], - ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42], + ['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, 32, ['name' => "Ook"]], - [Arsse::$user->id, 2102, ['name' => "Eek"]], - [Arsse::$user->id, 32, ['name' => "Eek"]], - [Arsse::$user->id, 32, ['name' => ""]], - [Arsse::$user->id, 32, ['name' => " "]], - [Arsse::$user->id, 32, ['name' => ""]], + [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")); @@ -622,38 +666,6 @@ class TestTinyTinyAPI extends Test\AbstractTest { } public function testRetrieveCategoryLists() { - $folders = [ - ['id' => 5, 'parent' => 3, 'children' => 0, 'name' => "Local"], - ['id' => 6, 'parent' => 3, 'children' => 0, 'name' => "National"], - ['id' => 4, 'parent' => null, 'children' => 0, 'name' => "Photography"], - ['id' => 3, 'parent' => null, 'children' => 2, 'name' => "Politics"], - ['id' => 2, 'parent' => 1, 'children' => 0, 'name' => "Rocketry"], - ['id' => 1, 'parent' => null, 'children' => 1, 'name' => "Science"], - ]; - $topFolders = [ - ['id' => 4, 'parent' => null, 'children' => 0, 'name' => "Photography"], - ['id' => 3, 'parent' => null, 'children' => 2, 'name' => "Politics"], - ['id' => 1, 'parent' => null, 'children' => 1, 'name' => "Science"], - ]; - $subscriptions = [ - ['folder' => null, 'top_folder' => null, 'unread' => 0], - ['folder' => 1, 'top_folder' => 1, 'unread' => 2], - ['folder' => 2, 'top_folder' => 1, 'unread' => 5], - ['folder' => 5, 'top_folder' => 3, 'unread' => 10], - ['folder' => 6, 'top_folder' => 3, 'unread' => 12], - ['folder' => 6, 'top_folder' => 3, 'unread' => 6], - ]; - $labels = [ - ['articles' => 0, 'read' => 0], - ['articles' => 100, 'read' => 94], - ['articles' => 2, 'read' => 0], - ]; - Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($folders)); - Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($topFolders)); - Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($subscriptions)); - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($labels)); - Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context - Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->starred(true))->thenReturn(4); $in = [ ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'include_empty' => true], ['op' => "getCategories", 'sid' => "PriestsOfSyrinx"], @@ -662,6 +674,12 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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(['total' => 10, 'unread' => 4, 'read' => 6]); $exp = [ [ ['id' => 5, 'title' => "Local", 'unread' => 10, 'order_id' => 1], @@ -718,4 +736,36 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($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(['total' => 10, 'unread' => 4, 'read' => 6]); + $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' => 2, 'auxcounter' => 2], + ['id' => 3, 'has_img' => 1, 'counter' => 2, 'updated' => "2016-05-23T06:40:02"], + ['id' => 1, 'has_img' => 0, 'counter' => 5, 'updated' => "2017-09-15T22:54:16"], + ['id' => 2, 'has_img' => 1, 'counter' => 10, 'updated' => "2011-11-11T11:11:11"], + ['id' => 5, 'has_img' => 0, 'counter' => 12, 'updated' => "2017-07-07T17:07:17"], + ['id' => 4, 'has_img' => 1, 'counter' => 6, 'updated' => "2017-10-09T15:58:34"], + ['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' => 8], + ]; + $this->assertResponse($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in)))); + } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 3f363fb..c7cdaa2 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -730,17 +730,29 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['read'=>false]); } - public function testCountStarredArticles() { + public function testCountArticles() { $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); - $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.org", (new Context)->starred(true))); - $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.net", (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))); } - public function testCountStarredArticlesWithoutAuthority() { + public function testCountArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); - Arsse::$db->articleCount($this->user, (new Context)->starred(true)); + 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() { From 4ab004bbab32ab1c3196673e5e88e85e22e9ab33 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 12 Oct 2017 09:18:37 -0400 Subject: [PATCH 21/66] Speed up SQLite timeout tests --- tests/Db/SQLite3/TestDbDriverSQLite3.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Db/SQLite3/TestDbDriverSQLite3.php b/tests/Db/SQLite3/TestDbDriverSQLite3.php index 4a2058d..475bc7d 100644 --- a/tests/Db/SQLite3/TestDbDriverSQLite3.php +++ b/tests/Db/SQLite3/TestDbDriverSQLite3.php @@ -18,6 +18,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest { $conf = new Conf(); Arsse::$conf = $conf; $conf->dbDriver = Db\SQLite3\Driver::class; + $conf->dbSQLite3Timeout = 0; $conf->dbSQLite3File = tempnam(sys_get_temp_dir(), 'ook'); $this->drv = new Db\SQLite3\Driver(true); $this->ch = new \SQLite3(Arsse::$conf->dbSQLite3File); From a343b78b02cbd8081eda98b2830edbff82acb09f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 13 Oct 2017 00:03:25 -0400 Subject: [PATCH 22/66] Make exporting the database test fixture easier --- lib/Db/SQLite3/Driver.php | 4 ++-- tests/Db/SQLite3/TestDbDriverSQLite3.php | 2 +- tests/Db/SQLite3/TestDbUpdateSQLite3.php | 2 +- tests/lib/Database/DriverSQLite3.php | 2 +- tests/lib/Database/Setup.php | 28 +++++++++++++++++------- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index d65ff2e..46b1110 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -16,13 +16,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { protected $db; - public function __construct() { + public function __construct(string $dbFile = null) { // check to make sure required extension is loaded if (!class_exists("SQLite3")) { throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore } // if no database file is specified in the configuration, use a suitable default - $dbFile = Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; + $dbFile = $dbFile ?? Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; $mode = \SQLITE3_OPEN_READWRITE | \SQLITE3_OPEN_CREATE; $timeout = Arsse::$conf->dbSQLite3Timeout * 1000; try { diff --git a/tests/Db/SQLite3/TestDbDriverSQLite3.php b/tests/Db/SQLite3/TestDbDriverSQLite3.php index 475bc7d..0c80b5b 100644 --- a/tests/Db/SQLite3/TestDbDriverSQLite3.php +++ b/tests/Db/SQLite3/TestDbDriverSQLite3.php @@ -20,7 +20,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest { $conf->dbDriver = Db\SQLite3\Driver::class; $conf->dbSQLite3Timeout = 0; $conf->dbSQLite3File = tempnam(sys_get_temp_dir(), 'ook'); - $this->drv = new Db\SQLite3\Driver(true); + $this->drv = new Db\SQLite3\Driver(); $this->ch = new \SQLite3(Arsse::$conf->dbSQLite3File); $this->ch->enableExceptions(true); } diff --git a/tests/Db/SQLite3/TestDbUpdateSQLite3.php b/tests/Db/SQLite3/TestDbUpdateSQLite3.php index a71c605..3441263 100644 --- a/tests/Db/SQLite3/TestDbUpdateSQLite3.php +++ b/tests/Db/SQLite3/TestDbUpdateSQLite3.php @@ -30,7 +30,7 @@ class TestDbUpdateSQLite3 extends Test\AbstractTest { Arsse::$conf = $conf; $this->base = $this->vfs->url(); $this->path = $this->base."/SQLite3/"; - $this->drv = new Db\SQLite3\Driver(true); + $this->drv = new Db\SQLite3\Driver(); } public function tearDown() { diff --git a/tests/lib/Database/DriverSQLite3.php b/tests/lib/Database/DriverSQLite3.php index a898a08..ca096e4 100644 --- a/tests/lib/Database/DriverSQLite3.php +++ b/tests/lib/Database/DriverSQLite3.php @@ -11,7 +11,7 @@ trait DriverSQLite3 { $this->markTestSkipped("SQLite extension not loaded"); } Arsse::$conf->dbSQLite3File = ":memory:"; - $this->drv = new Driver(true); + $this->drv = new Driver(); } public function nextID(string $table): int { diff --git a/tests/lib/Database/Setup.php b/tests/lib/Database/Setup.php index 3dfba36..edd9de4 100644 --- a/tests/lib/Database/Setup.php +++ b/tests/lib/Database/Setup.php @@ -48,15 +48,16 @@ trait Setup { $this->clearData(); } - public function primeDatabase(array $data): bool { - $tr = $this->drv->begin(); + public function primeDatabase(array $data, \JKingWeb\Arsse\Db\Driver $drv = null): bool { + $drv = $drv ?? $this->drv; + $tr = $drv->begin(); foreach ($data as $table => $info) { $cols = implode(",", array_keys($info['columns'])); $bindings = array_values($info['columns']); $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); - $s = $this->drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); + $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); foreach ($info['rows'] as $row) { - $this->assertEquals(1, $s->runArray($row)->changes()); + $s->runArray($row); } } $tr->commit(); @@ -64,6 +65,16 @@ trait Setup { return true; } + public function primeFile(string $file, array $data = null): bool { + $data = $data ?? $this->data; + $primed = $this->primed; + $drv = new \JKingWeb\Arsse\Db\SQLite3\Driver($file); + $drv->schemaUpdate(\JKingWeb\Arsse\Database::SCHEMA_VERSION); + $this->primeDatabase($data, $drv); + $this->primed = $primed; + return true; + } + public function compareExpectations(array $expected): bool { foreach ($expected as $table => $info) { $cols = implode(",", array_keys($info['columns'])); @@ -126,11 +137,12 @@ trait Setup { $rows[] = array_intersect_key($row, $keys); } // compare the result set to the expectations - foreach ($expected as $index => $exp) { - $this->assertContains($exp, $rows, "Result set does not contain record at array index $index."); - $found = array_search($exp, $rows, true); - unset($rows[$found]); + foreach ($rows as $row) { + $this->assertContains($row, $expected, "Result set contains unexpected record."); + $found = array_search($row, $expected); + unset($expected[$found]); } + $this->assertArraySubset($expected, [], "Expectations not in result set."); } } } From 2e395f3cec1bd5834f910a5bdd6a92b96e515b78 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 13 Oct 2017 00:04:26 -0400 Subject: [PATCH 23/66] Complete backend support for labels --- lib/Database.php | 136 +++++++++++++++---- lib/Misc/Context.php | 10 ++ sql/SQLite3/1.sql | 2 + tests/Misc/TestContext.php | 2 + tests/lib/Database/SeriesArticle.php | 35 +++++ tests/lib/Database/SeriesLabel.php | 196 +++++++++++++-------------- 6 files changed, 248 insertions(+), 133 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 7d745a7..71e85cb 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -880,6 +880,15 @@ class Database { // 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->label() || $context->labelName()) { + 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 edition offset if ($context->oldestEdition()) { $q->setWhere("edition >= ?", "int", $context->oldestEdition); @@ -932,9 +941,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; // sanitize input $values = [ isset($data['read']) ? $data['read'] : null, @@ -1139,11 +1146,10 @@ class Database { return $this->db->prepare( "SELECT id,name, - (select count(*) from arsse_label_members join arsse_subscriptions on arsse_subscriptions.owner is arsse_labels.owner where label is arsse_labels.id) as articles, + (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 - join arsse_subscriptions on arsse_subscriptions.owner is arsse_labels.owner - where label is arsse_labels.id and read is 1 + where label is id and assigned is 1 and read is 1 ) as read FROM arsse_labels where owner is ? and articles >= ? ", "str", "int" @@ -1154,13 +1160,7 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); - } + $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(); @@ -1174,22 +1174,20 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); - } + $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 owner is ? and label is arsse_labels.id) as articles + (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 ? - ", "str", $type, "str" - )->run($user, $id, $user)->getRow(); + ", $type, "str" + )->run($id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } @@ -1200,13 +1198,7 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - 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" => __FUNCTION__, "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" => __FUNCTION__, "field" => "label", 'type' => "string"]); - } + $this->labelValidateId($user, $id, $byName, false); if (isset($data['name'])) { $this->labelValidateName($data['name']); } @@ -1227,6 +1219,90 @@ class Database { 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): bool { + 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 (bool) $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)) { diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index ca37b3d..b1864f1 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -21,6 +21,8 @@ class Context { public $article; public $editions; public $articles; + public $label; + public $labelName; protected $props = []; @@ -113,4 +115,12 @@ 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); + } } diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 50e7567..94970ba 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -20,6 +20,8 @@ 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; diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index 49ab9bd..4547c58 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -35,6 +35,8 @@ class TestContext extends Test\AbstractTest { 'notModifiedSince' => new \DateTime(), 'editions' => [1,2], 'articles' => [1,2], + 'label' => 2112, + 'labelName' => "Rush", ]; $times = ['modifiedSince','notModifiedSince']; $c = new Context; diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index c7cdaa2..0ecccfd 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -206,6 +206,35 @@ trait SeriesArticle { [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], + ], + ], ]; protected $matches = [ [ @@ -355,6 +384,12 @@ 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)); + // label by ID + $this->compareIds([1,19], (new Context)->label(1)); + $this->compareIds([1,5,20], (new Context)->label(2)); + // label by name + $this->compareIds([1,19], (new Context)->labelName("Interesting")); + $this->compareIds([1,5,20], (new Context)->labelName("Fascinating")); } public function testListArticlesOfAMissingFolder() { diff --git a/tests/lib/Database/SeriesLabel.php b/tests/lib/Database/SeriesLabel.php index f787ed6..26c0157 100644 --- a/tests/lib/Database/SeriesLabel.php +++ b/tests/lib/Database/SeriesLabel.php @@ -216,104 +216,30 @@ trait SeriesLabel { [1,"john.doe@example.com","Interesting"], [2,"john.doe@example.com","Fascinating"], [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], ], - ] - ]; - protected $matches = [ - [ - 'id' => 101, - 'url' => 'http://example.com/1', - 'title' => 'Article title 1', - 'author' => '', - 'content' => '

Article content 1

', - 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', - '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, - ], - [ - 'id' => 102, - 'url' => 'http://example.com/2', - 'title' => 'Article title 2', - 'author' => '', - '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", - ], - [ - 'id' => 103, - 'url' => 'http://example.com/3', - 'title' => 'Article title 3', - 'author' => '', - 'content' => '

Article content 3

', - 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', - 'published_date' => '2000-01-03 00:00:00', - 'edited_date' => '2000-01-03 00:00:03', - 'modified_date' => '2000-01-03 03:00:00', - 'unread' => 1, - 'starred' => 1, - 'edition' => 203, - 'subscription' => 9, - 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', - 'media_url' => "http://example.com/video", - 'media_type' => "video/webm", ], - [ - 'id' => 104, - 'url' => 'http://example.com/4', - 'title' => 'Article title 4', - 'author' => '', - 'content' => '

Article content 4

', - 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', - 'published_date' => '2000-01-04 00:00:00', - 'edited_date' => '2000-01-04 00:00:04', - 'modified_date' => '2000-01-04 04:00:00', - 'unread' => 0, - 'starred' => 1, - 'edition' => 204, - 'subscription' => 9, - 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', - 'media_url' => "http://example.com/image", - 'media_type' => "image/svg+xml", - ], - [ - 'id' => 105, - 'url' => 'http://example.com/5', - 'title' => 'Article title 5', - 'author' => '', - 'content' => '

Article content 5

', - 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', - 'published_date' => '2000-01-05 00:00:00', - 'edited_date' => '2000-01-05 00:00:05', - 'modified_date' => '2000-01-05 05:00:00', - 'unread' => 1, - 'starred' => 0, - 'edition' => 305, - 'subscription' => 10, - 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', - 'media_url' => "http://example.com/audio", - 'media_type' => "audio/ogg", + '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->checkTables = ['arsse_labels' => ["id","owner","name"],]; + $this->checkLabels = ['arsse_labels' => ["id","owner","name"]]; + $this->checkMembers = ['arsse_label_members' => ["label","article","subscription","assigned"]]; $this->user = "john.doe@example.com"; } @@ -322,7 +248,7 @@ trait SeriesLabel { $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->checkTables); + $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"]; $this->compareExpectations($state); } @@ -355,8 +281,9 @@ trait SeriesLabel { public function testListLabels() { $exp = [ - ['id' => 2, 'name' => "Fascinating", 'articles' => 0], - ['id' => 1, 'name' => "Interesting", 'articles' => 0], + ['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 = [ @@ -364,10 +291,8 @@ trait SeriesLabel { ]; $this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com")); $exp = []; - $this->assertResult($exp, Arsse::$db->labelList("admin@example.net")); + $this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com", false)); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList"); - Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "labelList"); - Phake::verify(Arsse::$user)->authorize("admin@example.net", "labelList"); } public function testListLabelsWithoutAuthority() { @@ -379,7 +304,7 @@ trait SeriesLabel { 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->checkTables); + $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); $this->compareExpectations($state); } @@ -387,7 +312,7 @@ trait SeriesLabel { 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->checkTables); + $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); $this->compareExpectations($state); } @@ -422,7 +347,8 @@ trait SeriesLabel { $exp = [ 'id' => 2, 'name' => "Fascinating", - 'articles' => 0, + '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)); @@ -462,7 +388,7 @@ trait SeriesLabel { 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->checkTables); + $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; $this->compareExpectations($state); } @@ -470,7 +396,7 @@ trait SeriesLabel { 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->checkTables); + $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; $this->compareExpectations($state); } @@ -520,4 +446,68 @@ trait SeriesLabel { $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])); + } } From fbbf7512147863cac755181deb125d1f8ac1c4a1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 13 Oct 2017 17:05:06 -0400 Subject: [PATCH 24/66] Implement the TTRSS getLabels operation; fixes #89 --- lib/Database.php | 36 +++++++++----- lib/REST/TinyTinyRSS/API.php | 21 +++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 55 ++++++++++++++++++++-- tests/lib/Database/SeriesArticle.php | 15 ++++++ 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 71e85cb..f8ef9da 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -937,6 +937,17 @@ class Database { 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; + $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): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1000,17 +1011,6 @@ class Database { return (bool) $out; } - 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; - $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 articleStarred(string $user): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1026,6 +1026,20 @@ class Database { )->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 articleCleanup(): bool { $query = $this->db->prepare( "WITH target_feed(id,subs) as (". diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index b6f10ff..4c3c97d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -553,6 +553,27 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return ($id * -1 - self::LABEL_OFFSET); } + public function opGetLabels(array $data): array { + // this function doesn't complain about invalid article IDs + $article = (isset($data['article_id']) && ValueInfo::id($data['article_id'])) ? (int) $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' => isset($data['caption']) ? $data['caption'] : "", diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 77a98f1..e2ac0e9 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -38,13 +38,13 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'favicon' => 'http://example.com/4.png'], ]; protected $labels = [ - ['id' => 5, 'articles' => 0, 'read' => 0], - ['id' => 3, 'articles' => 100, 'read' => 94], - ['id' => 1, 'articles' => 2, 'read' => 0], + ['id' => 5, 'articles' => 0, 'read' => 0, 'name' => "Interesting"], + ['id' => 3, 'articles' => 100, 'read' => 94, 'name' => "Fascinating"], + ['id' => 1, 'articles' => 2, 'read' => 0, 'name' => "Logical"], ]; protected $usedLabels = [ - ['id' => 3, 'articles' => 100, 'read' => 94], - ['id' => 1, 'articles' => 2, 'read' => 0], + ['id' => 3, 'articles' => 100, 'read' => 94, 'name' => "Fascinating"], + ['id' => 1, 'articles' => 2, 'read' => 0, 'name' => "Logical"], ]; protected function respGood($content = null, $seq = 0): Response { @@ -768,4 +768,49 @@ class TestTinyTinyAPI extends Test\AbstractTest { ]; $this->assertResponse($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in)))); } + + public function testRetrieveLabelList() { + $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' => -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' => 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' => 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], + ], + ]; + for ($a = 0; $a < sizeof($in); $a++) { + $this->assertResponse($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + } + } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 0ecccfd..83c4d80 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -805,4 +805,19 @@ 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 testListTheLabelsOfAnArticleWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->articleLabelsGet("john.doe@example.com", 1); + } } From 4e3369cd032700c9366642034f9a13ea71ba1ae6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 15 Oct 2017 12:47:07 -0400 Subject: [PATCH 25/66] List all valid TTRSS input --- lib/REST/TinyTinyRSS/API.php | 56 +++++++++++++++++++++- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 2 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 4c3c97d..a0136e3 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -32,9 +32,51 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; const LABEL_OFFSET = 1024; + const VALID_INPUT = [ + 'op' => "str", + 'sid' => "str", + 'user' => "str", + 'password' => "str", + 'include_empty' => "bool", + 'unread_only' => "bool", + 'enable_nested' => "bool", + 'caption' => "str", + 'parent_id' => "int", + 'category_id' => "int", + 'feed_url' => "str", + 'login' => "str", + 'feed_id' => "int", + 'article_id' => "int", + 'label_id' => "int", + 'article_ids' => "str", + 'assign' => "bool", + 'is_cat' => "bool", + 'cat_id' => "int", + 'limit' => "int", + 'offset' => "int", + 'include_nested' => "bool", + 'skip' => "int", + 'filter' => "str", + 'show_excerpt' => "bool", + 'show_content' => "bool", + 'view_mode' => "str", + 'include_attachments' => "bool", + 'since_id' => "int", + 'order_by' => "str", + 'sanitize' => "bool", + 'force_update' => "bool", + 'has_sandbox' => "bool", + 'include_header' => "bool", + 'search' => "str", + 'search_mode' => "str", + 'match_on' => "str", + 'mode' => "int", + 'field' => "int", + 'data' => "str", + ]; const FATAL_ERR = [ - 'seq' => null, - 'status' => 1, + 'seq' => null, + 'status' => 1, 'content' => ['error' => "NOT_LOGGED_IN"], ]; @@ -618,4 +660,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return null; } + + public function opSetArticleLabel(array $data): array { + if (!isset($data['article_ids']) || !isset($data['label_id'])) { + throw new Exception("INCORRECT_USAGE"); + } + $label = $this->labelIn($data['label_id']); + $articles = explode(",", (string) $data['article_ids']); + $assign = ValueInfo::bool(isset($data['assign']) ? $data['assign'] : null, false); + + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index e2ac0e9..81e6df4 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -769,7 +769,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertResponse($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in)))); } - public function testRetrieveLabelList() { + public function testRetrieveTheLabelList() { $in = [ ['op' => "getLabels", 'sid' => "PriestsOfSyrinx"], ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 1], From 8c6c49d588e4e8180169851c6cb677258cc60515 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 19 Oct 2017 22:58:42 -0400 Subject: [PATCH 26/66] Merge changes from master --- composer.lock | 190 ++++++++--------- lib/AbstractException.php | 2 + lib/ExceptionType.php | 6 + lib/Misc/Date.php | 51 ++--- lib/Misc/ValueInfo.php | 259 ++++++++++++++++++++++- lib/REST/AbstractHandler.php | 49 +---- lib/REST/NextCloudNews/V1_2.php | 139 +++++------- locale/en.php | 11 + tests/Misc/TestValueInfo.php | 239 ++++++++++++++++++++- tests/REST/NextCloudNews/TestNCNV1_2.php | 18 +- 10 files changed, 669 insertions(+), 295 deletions(-) create mode 100644 lib/ExceptionType.php diff --git a/composer.lock b/composer.lock index 844cdb5..597efa5 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": "d00fd63e825db5ce16878c1639f362f3", + "content-hash": "1193e4106b6c84c545e6091560214ad5", "packages": [ { "name": "docopt/docopt", @@ -760,16 +760,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.2.7", + "version": "v2.2.8", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "b6202ccad4c00778887e7e8282d52f854802b59a" + "reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b6202ccad4c00778887e7e8282d52f854802b59a", - "reference": "b6202ccad4c00778887e7e8282d52f854802b59a", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/aca23e791784eade7b377d578d6dfc6fcf1398d2", + "reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2", "shasum": "" }, "require": { @@ -778,7 +778,7 @@ "ext-json": "*", "ext-tokenizer": "*", "gecko-packages/gecko-php-unit": "^2.0", - "php": "^5.3.6 || >=7.0 <7.2", + "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", @@ -841,7 +841,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2017-09-11T14:27:07+00:00" + "time": "2017-09-29T15:07:49+00:00" }, { "name": "gecko-packages/gecko-php-unit", @@ -1136,16 +1136,16 @@ }, { "name": "jms/serializer", - "version": "1.8.1", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/serializer.git", - "reference": "ce65798f722c836f16d5d7d2e3ca9d21e0fb4331" + "reference": "f4683f41ebf21e60667447bb49939bee35807c3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/ce65798f722c836f16d5d7d2e3ca9d21e0fb4331", - "reference": "ce65798f722c836f16d5d7d2e3ca9d21e0fb4331", + "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/f4683f41ebf21e60667447bb49939bee35807c3c", + "reference": "f4683f41ebf21e60667447bb49939bee35807c3c", "shasum": "" }, "require": { @@ -1184,7 +1184,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -1215,7 +1215,7 @@ "serialization", "xml" ], - "time": "2017-07-13T11:23:56+00:00" + "time": "2017-09-28T15:17:28+00:00" }, { "name": "justinrainbow/json-schema", @@ -1539,16 +1539,16 @@ }, { "name": "paragonie/random_compat", - "version": "v2.0.10", + "version": "v2.0.11", "source": { "type": "git", "url": "https://github.com/paragonie/random_compat.git", - "reference": "634bae8e911eefa89c1abfbf1b66da679ac8f54d" + "reference": "5da4d3c796c275c55f057af5a643ae297d96b4d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/634bae8e911eefa89c1abfbf1b66da679ac8f54d", - "reference": "634bae8e911eefa89c1abfbf1b66da679ac8f54d", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/5da4d3c796c275c55f057af5a643ae297d96b4d8", + "reference": "5da4d3c796c275c55f057af5a643ae297d96b4d8", "shasum": "" }, "require": { @@ -1583,7 +1583,7 @@ "pseudorandom", "random" ], - "time": "2017-03-13T16:27:32+00:00" + "time": "2017-09-27T21:40:39+00:00" }, { "name": "pear/archive_tar", @@ -3632,16 +3632,16 @@ }, { "name": "symfony/config", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "0b8541d18507d10204a08384640ff6df3c739ebe" + "reference": "1dbeaa8e2db4b29159265867efff075ad961558c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/0b8541d18507d10204a08384640ff6df3c739ebe", - "reference": "0b8541d18507d10204a08384640ff6df3c739ebe", + "url": "https://api.github.com/repos/symfony/config/zipball/1dbeaa8e2db4b29159265867efff075ad961558c", + "reference": "1dbeaa8e2db4b29159265867efff075ad961558c", "shasum": "" }, "require": { @@ -3684,20 +3684,20 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2017-04-12T14:07:15+00:00" + "time": "2017-10-04T18:56:36+00:00" }, { "name": "symfony/console", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c0807a2ca978e64d8945d373a9221a5c35d1a253" + "reference": "f81549d2c5fdee8d711c9ab3c7e7362353ea5853" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c0807a2ca978e64d8945d373a9221a5c35d1a253", - "reference": "c0807a2ca978e64d8945d373a9221a5c35d1a253", + "url": "https://api.github.com/repos/symfony/console/zipball/f81549d2c5fdee8d711c9ab3c7e7362353ea5853", + "reference": "f81549d2c5fdee8d711c9ab3c7e7362353ea5853", "shasum": "" }, "require": { @@ -3745,7 +3745,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2017-08-27T14:29:03+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/debug", @@ -3806,16 +3806,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "1377400fd641d7d1935981546aaef780ecd5bf6d" + "reference": "7fe089232554357efb8d4af65ce209fc6e5a2186" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1377400fd641d7d1935981546aaef780ecd5bf6d", - "reference": "1377400fd641d7d1935981546aaef780ecd5bf6d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7fe089232554357efb8d4af65ce209fc6e5a2186", + "reference": "7fe089232554357efb8d4af65ce209fc6e5a2186", "shasum": "" }, "require": { @@ -3862,7 +3862,7 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2017-06-02T07:47:27+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/filesystem", @@ -3915,16 +3915,16 @@ }, { "name": "symfony/finder", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "4f4e84811004e065a3bb5ceeb1d9aa592630f9ad" + "reference": "a945724b201f74d543e356f6059c930bb8d10c92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/4f4e84811004e065a3bb5ceeb1d9aa592630f9ad", - "reference": "4f4e84811004e065a3bb5ceeb1d9aa592630f9ad", + "url": "https://api.github.com/repos/symfony/finder/zipball/a945724b201f74d543e356f6059c930bb8d10c92", + "reference": "a945724b201f74d543e356f6059c930bb8d10c92", "shasum": "" }, "require": { @@ -3960,11 +3960,11 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2017-06-01T20:52:29+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/options-resolver", - "version": "v3.3.9", + "version": "v3.3.10", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", @@ -4018,16 +4018,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803" + "reference": "2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7c8fae0ac1d216eb54349e6a8baa57d515fe8803", - "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296", + "reference": "2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296", "shasum": "" }, "require": { @@ -4039,7 +4039,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -4073,20 +4073,20 @@ "portable", "shim" ], - "time": "2017-06-14T15:44:48+00:00" + "time": "2017-10-11T12:05:26+00:00" }, { "name": "symfony/polyfill-php54", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php54.git", - "reference": "b7763422a5334c914ef0298ed21b253d25913a6e" + "reference": "d7810a14b2c6c1aff415e1bb755f611b3d5327bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php54/zipball/b7763422a5334c914ef0298ed21b253d25913a6e", - "reference": "b7763422a5334c914ef0298ed21b253d25913a6e", + "url": "https://api.github.com/repos/symfony/polyfill-php54/zipball/d7810a14b2c6c1aff415e1bb755f611b3d5327bc", + "reference": "d7810a14b2c6c1aff415e1bb755f611b3d5327bc", "shasum": "" }, "require": { @@ -4095,7 +4095,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -4131,20 +4131,20 @@ "portable", "shim" ], - "time": "2017-06-14T15:44:48+00:00" + "time": "2017-10-11T12:05:26+00:00" }, { "name": "symfony/polyfill-php55", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php55.git", - "reference": "29b1381d66f16e0581aab0b9f678ccf073288f68" + "reference": "b64e7f0c37ecf144ecc16668936eef94e628fbfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php55/zipball/29b1381d66f16e0581aab0b9f678ccf073288f68", - "reference": "29b1381d66f16e0581aab0b9f678ccf073288f68", + "url": "https://api.github.com/repos/symfony/polyfill-php55/zipball/b64e7f0c37ecf144ecc16668936eef94e628fbfd", + "reference": "b64e7f0c37ecf144ecc16668936eef94e628fbfd", "shasum": "" }, "require": { @@ -4154,7 +4154,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -4187,20 +4187,20 @@ "portable", "shim" ], - "time": "2017-06-14T15:44:48+00:00" + "time": "2017-10-11T12:05:26+00:00" }, { "name": "symfony/polyfill-php70", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php70.git", - "reference": "b6482e68974486984f59449ecea1fbbb22ff840f" + "reference": "0442b9c0596610bd24ae7b5f0a6cdbbc16d9fcff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/b6482e68974486984f59449ecea1fbbb22ff840f", - "reference": "b6482e68974486984f59449ecea1fbbb22ff840f", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/0442b9c0596610bd24ae7b5f0a6cdbbc16d9fcff", + "reference": "0442b9c0596610bd24ae7b5f0a6cdbbc16d9fcff", "shasum": "" }, "require": { @@ -4210,7 +4210,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -4246,20 +4246,20 @@ "portable", "shim" ], - "time": "2017-06-14T15:44:48+00:00" + "time": "2017-10-11T12:05:26+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "8abc9097f5001d310f0edba727469c988acc6ea7" + "reference": "6de4f4884b97abbbed9f0a84a95ff2ff77254254" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/8abc9097f5001d310f0edba727469c988acc6ea7", - "reference": "8abc9097f5001d310f0edba727469c988acc6ea7", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/6de4f4884b97abbbed9f0a84a95ff2ff77254254", + "reference": "6de4f4884b97abbbed9f0a84a95ff2ff77254254", "shasum": "" }, "require": { @@ -4268,7 +4268,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -4301,20 +4301,20 @@ "portable", "shim" ], - "time": "2017-07-11T13:25:55+00:00" + "time": "2017-10-11T12:05:26+00:00" }, { "name": "symfony/process", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8" + "reference": "26c9fb02bf06bd6b90f661a5bd17e510810d0176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8", - "reference": "57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8", + "url": "https://api.github.com/repos/symfony/process/zipball/26c9fb02bf06bd6b90f661a5bd17e510810d0176", + "reference": "26c9fb02bf06bd6b90f661a5bd17e510810d0176", "shasum": "" }, "require": { @@ -4350,20 +4350,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2017-07-03T08:04:30+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/stopwatch", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "e02577b841394a78306d7b547701bb7bb705bad5" + "reference": "28ee62ea4736431ca817cdaebcb005663e9cd1cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/e02577b841394a78306d7b547701bb7bb705bad5", - "reference": "e02577b841394a78306d7b547701bb7bb705bad5", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/28ee62ea4736431ca817cdaebcb005663e9cd1cb", + "reference": "28ee62ea4736431ca817cdaebcb005663e9cd1cb", "shasum": "" }, "require": { @@ -4399,7 +4399,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2017-04-12T14:07:15+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/translation", @@ -4467,16 +4467,16 @@ }, { "name": "symfony/validator", - "version": "v2.8.27", + "version": "v2.8.28", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "864ba6865e253a7ffc3db5629af676cfdc3bd104" + "reference": "1531ddfd96efd1b2c231cbf45f22e652a8f67925" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/864ba6865e253a7ffc3db5629af676cfdc3bd104", - "reference": "864ba6865e253a7ffc3db5629af676cfdc3bd104", + "url": "https://api.github.com/repos/symfony/validator/zipball/1531ddfd96efd1b2c231cbf45f22e652a8f67925", + "reference": "1531ddfd96efd1b2c231cbf45f22e652a8f67925", "shasum": "" }, "require": { @@ -4536,20 +4536,20 @@ ], "description": "Symfony Validator Component", "homepage": "https://symfony.com", - "time": "2017-08-27T14:29:03+00:00" + "time": "2017-10-01T21:00:16+00:00" }, { "name": "symfony/yaml", - "version": "v3.3.9", + "version": "v3.3.10", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0" + "reference": "8c7bf1e7d5d6b05a690b715729cb4cd0c0a99c46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/1d8c2a99c80862bdc3af94c1781bf70f86bccac0", - "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8c7bf1e7d5d6b05a690b715729cb4cd0c0a99c46", + "reference": "8c7bf1e7d5d6b05a690b715729cb4cd0c0a99c46", "shasum": "" }, "require": { @@ -4591,7 +4591,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2017-07-29T21:54:42+00:00" + "time": "2017-10-05T14:43:42+00:00" }, { "name": "theseer/tokenizer", @@ -4635,16 +4635,16 @@ }, { "name": "twig/twig", - "version": "v1.34.4", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "f878bab48edb66ad9c6ed626bf817f60c6c096ee" + "reference": "daa657073e55b0a78cce8fdd22682fddecc6385f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/f878bab48edb66ad9c6ed626bf817f60c6c096ee", - "reference": "f878bab48edb66ad9c6ed626bf817f60c6c096ee", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/daa657073e55b0a78cce8fdd22682fddecc6385f", + "reference": "daa657073e55b0a78cce8fdd22682fddecc6385f", "shasum": "" }, "require": { @@ -4658,7 +4658,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.34-dev" + "dev-master": "1.35-dev" } }, "autoload": { @@ -4696,7 +4696,7 @@ "keywords": [ "templating" ], - "time": "2017-07-04T13:19:31+00:00" + "time": "2017-09-27T18:06:46+00:00" }, { "name": "webmozart/assert", diff --git a/lib/AbstractException.php b/lib/AbstractException.php index c29f6fe..2ae34d3 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -5,6 +5,8 @@ namespace JKingWeb\Arsse; abstract class AbstractException extends \Exception { const CODES = [ "Exception.uncoded" => -1, "Exception.unknown" => 10000, + "ExceptionType.strictFailure" => 10011, + "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, "Lang/Exception.fileMissing" => 10102, "Lang/Exception.fileUnreadable" => 10103, diff --git a/lib/ExceptionType.php b/lib/ExceptionType.php new file mode 100644 index 0000000..16094ce --- /dev/null +++ b/lib/ExceptionType.php @@ -0,0 +1,6 @@ + ["!Y-m-d\TH:i:s", "Y-m-d\TH:i:s\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets + 'iso8601m' => ["!Y-m-d\TH:i:s.u", "Y-m-d\TH:i:s.u\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets + 'microtime' => ["U.u", "0.u00 U" ], // NOTE: the actual input format at the user level matches the output format; pre-processing is required for PHP not to fail + 'http' => ["!D, d M Y H:i:s \G\M\T", "D, d M Y H:i:s \G\M\T"], + 'sql' => ["!Y-m-d H:i:s", "Y-m-d H:i:s" ], + 'date' => ["!Y-m-d", "Y-m-d" ], + 'time' => ["!H:i:s", "H:i:s" ], + 'unix' => ["U", "U" ], + 'float' => ["U.u", "U.u" ], + ]; + public static function transform($date, string $outFormat = null, string $inFormat = null, bool $inLocal = false) { $date = self::normalize($date, $inFormat, $inLocal); if (is_null($date) || is_null($outFormat)) { @@ -14,7 +26,7 @@ class Date { } switch ($outFormat) { case 'http': $f = "D, d M Y H:i:s \G\M\T"; break; - case 'iso8601': $f = "Y-m-d\TH:i:s"; break; + case 'iso8601': $f = "Y-m-d\TH:i:s"; break; case 'sql': $f = "Y-m-d H:i:s"; break; case 'date': $f = "Y-m-d"; break; case 'time': $f = "H:i:s"; break; @@ -23,41 +35,8 @@ class Date { return $date->format($f); } - public static function normalize($date, string $inFormat = null, bool $inLocal = false) { - if ($date instanceof \DateTimeInterface) { - return $date; - } elseif (is_numeric($date)) { - $time = (int) $date; - } elseif ($date===null) { - return null; - } elseif (is_string($date)) { - try { - $tz = (!$inLocal) ? new \DateTimeZone("UTC") : null; - if (!is_null($inFormat)) { - switch ($inFormat) { - case 'http': $f = "D, d M Y H:i:s \G\M\T"; break; - case 'iso8601': $f = "Y-m-d\TH:i:sP"; break; - case 'sql': $f = "Y-m-d H:i:s"; break; - case 'date': $f = "Y-m-d"; break; - case 'time': $f = "H:i:s"; break; - default: $f = $inFormat; break; - } - return \DateTime::createFromFormat("!".$f, $date, $tz); - } else { - return new \DateTime($date, $tz); - } - } catch (\Throwable $e) { - return null; - } - } elseif (is_bool($date)) { - return null; - } else { - $time = (int) $date; - } - $tz = (!$inLocal) ? new \DateTimeZone("UTC") : null; - $d = new \DateTime("now", $tz); - $d->setTimestamp($time); - return $d; + public static function normalize($date, string $inFormat = null) { + return ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat); } public static function add(string $interval, $date = null): \DateTimeInterface { diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index c382836..09dca6d 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -2,6 +2,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Misc; +use JKingWeb\Arsse\ExceptionType; + class ValueInfo { // universal const VALID = 1 << 0; @@ -9,9 +11,232 @@ class ValueInfo { // integers const ZERO = 1 << 2; const NEG = 1 << 3; + const FLOAT = 1 << 4; // strings const EMPTY = 1 << 2; const WHITE = 1 << 3; + //normalization types + const T_MIXED = 0; // pass through unchanged + const T_NULL = 1; // convert to null + const T_BOOL = 2; // convert to boolean + const T_INT = 3; // convert to integer + const T_FLOAT = 4; // convert to floating point + const T_DATE = 5; // convert to DateTimeInterface instance + const T_STRING = 6; // convert to string + const T_ARRAY = 7; // convert to array + //normalization modes + const M_NULL = 1 << 28; // pass nulls through regardless of target type + const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match + const M_STRICT = 1 << 30; // throw an exception if the type doesn't match + const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable + + public function normalize($value, int $type, string $dateFormat = null) { + $allowNull = ($type & self::M_NULL); + $strict = ($type & (self::M_STRICT | self::M_DROP)); + $drop = ($type & self::M_DROP); + $arrayVal = ($type & self::M_ARRAY); + $type = ($type & ~(self::M_NULL | self::M_DROP | self::M_STRICT | self::M_ARRAY)); + // if the value is null and this is allowed, simply return + if ($allowNull && is_null($value)) { + return null; + } + // if the value is supposed to be an array, handle it specially + if ($arrayVal) { + $value = self::normalize($value, self::T_ARRAY); + foreach ($value as $key => $v) { + $value[$key] = self::normalize($v, $type | ($allowNull ? self::M_NULL : 0) | ($strict ? self::M_STRICT : 0) | ($drop ? self::M_DROP : 0), $dateFormat); + } + return $value; + } + switch ($type) { + case self::T_MIXED: + return $value; + case self::T_NULL: + return null; + case self::T_BOOL: + if (is_bool($value)) { + return $value; + } + $out = self::bool($value); + if ($strict && is_null($out)) { + // if strict and input is not a boolean, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } elseif (is_float($value) && is_nan($value)) { + return false; + } elseif (is_null($out)) { + // if not strict and input is not a boolean, return a simple type-cast + return (bool) $value; + } + return $out; + case self::T_INT: + if (is_int($value)) { + return $value; + } elseif ($value instanceof \DateTimeInterface) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return (!$drop) ? (int) $value->getTimestamp(): null; + } + $info = self::int($value); + if ($strict && !($info & self::VALID)) { + // if strict and input is not an integer, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } elseif (is_bool($value)) { + return (int) $value; + } elseif ($info & (self::VALID | self::FLOAT)) { + $out = strtolower((string) $value); + if (strpos($out, "e")) { + return (int) (float) $out; + } else { + return (int) $out; + } + } else { + return 0; + } + case self::T_FLOAT: + if (is_float($value)) { + return $value; + } elseif ($value instanceof \DateTimeInterface) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return (!$drop) ? (float) $value->getTimestamp(): null; + } elseif (is_bool($value) && $strict) { + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } + $out = filter_var($value, \FILTER_VALIDATE_FLOAT); + if ($strict && $out===false) { + // if strict and input is not a float, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } + return (float) $out; + case self::T_STRING: + if (is_string($value)) { + return $value; + } + if ($value instanceof \DateTimeImmutable) { + return $value->setTimezone(new \DateTimeZone("UTC"))->format(Date::FORMAT['iso8601'][1]); + } elseif ($value instanceof \DateTime) { + $out = clone $value; + $out->setTimezone(new \DateTimeZone("UTC")); + return $out->format(Date::FORMAT['iso8601'][1]); + } elseif (is_float($value) && is_finite($value)) { + $out = (string) $value; + if(!strpos($out, "E")) { + return $out; + } else { + $out = sprintf("%F", $value); + return substr($out, -2)==".0" ? (string) (int) $out : $out; + } + } + $info = self::str($value); + if (!($info & self::VALID)) { + if ($drop) { + return null; + } elseif ($strict) { + // if strict and input is not a string, this is an error + throw new ExceptionType("strictFailure", $type); + } elseif (!is_scalar($value)) { + return ""; + } else { + return (string) $value; + } + } else { + return (string) $value; + } + case self::T_DATE: + if ($value instanceof \DateTimeImmutable) { + return $value->setTimezone(new \DateTimeZone("UTC")); + } elseif ($value instanceof \DateTime) { + $out = clone $value; + $out->setTimezone(new \DateTimeZone("UTC")); + return $out; + } elseif (is_int($value)) { + return \DateTime::createFromFormat("U", (string) $value, new \DateTimeZone("UTC")); + } elseif (is_float($value)) { + return \DateTime::createFromFormat("U.u", sprintf("%F", $value), new \DateTimeZone("UTC")); + } elseif (is_string($value)) { + try { + if (!is_null($dateFormat)) { + $out = false; + if ($dateFormat=="microtime") { + // PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float + if (preg_match("<^0\.\d{6}00 \d+$>", $value)) { + $value = substr($value,11).".".substr($value,2,6); + } else { + throw new \Exception; + } + } + $f = isset(Date::FORMAT[$dateFormat]) ? Date::FORMAT[$dateFormat][0] : $dateFormat; + if ($dateFormat=="iso8601" || $dateFormat=="iso8601m") { + // DateTime::createFromFormat() doesn't provide one catch-all for ISO 8601 timezone specifiers, so we try all of them till one works + if ($dateFormat=="iso8601m") { + $f2 = Date::FORMAT["iso8601"][0]; + $zones = [$f."", $f."\Z", $f."P", $f."O", $f2."", $f2."\Z", $f2."P", $f2."O"]; + } else { + $zones = [$f."", $f."\Z", $f."P", $f."O"]; + } + do { + $ftz = array_shift($zones); + $out = \DateTime::createFromFormat($ftz, $value, new \DateTimeZone("UTC")); + } while (!$out && $zones); + } else { + $out = \DateTime::createFromFormat($f, $value, new \DateTimeZone("UTC")); + } + if (!$out) { + throw new \Exception; + } + return $out; + } else { + return new \DateTime($value, new \DateTimeZone("UTC")); + } + } catch (\Exception $e) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return null; + } + } elseif ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return null; + case self::T_ARRAY: + if (is_array($value)) { + return $value; + } elseif ($value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + $out[$k] = $v; + } + return $out; + } else { + if ($drop) { + return null; + } elseif ($strict) { + // if strict and input is not a string, this is an error + throw new ExceptionType("strictFailure", $type); + } elseif (is_null($value) || (is_float($value) && is_nan($value))) { + return []; + } else { + return [$value]; + } + } + default: + throw new ExceptionType("typeUnknown", $type); // @codeCoverageIgnore + } + } public static function int($value): int { $out = 0; @@ -19,28 +244,42 @@ class ValueInfo { // check if the input is null return self::NULL; } elseif (is_string($value) || (is_object($value) && method_exists($value, "__toString"))) { - $value = (string) $value; + $value = strtolower((string) $value); // normalize a string an integer or float if possible if (!strlen($value)) { // the empty string is equivalent to null when evaluating an integer return self::NULL; - } elseif (filter_var($value, \FILTER_VALIDATE_FLOAT) !== false && !fmod((float) $value, 1)) { + } + // interpret the value as a float + $float = filter_var($value, \FILTER_VALIDATE_FLOAT); + if ($float !== false) { + if (!fmod($float, 1)) { + // an integral float is acceptable + $value = (int) (!strpos($value, "e") ? $value : $float); + } else { + $out += self::FLOAT; + $value = $float; + } + } else { + return $out; + } + } elseif (is_float($value)) { + if (!fmod($value, 1)) { // an integral float is acceptable $value = (int) $value; } else { - return $out; + $out += self::FLOAT; } - } elseif (is_float($value) && !fmod($value, 1)) { - // an integral float is acceptable - $value = (int) $value; } elseif (!is_int($value)) { // if the value is not an integer or integral float, stop return $out; } // mark validity - $out += self::VALID; + if (is_int($value)) { + $out += self::VALID; + } // mark zeroness - if ($value==0) { + if (!$value) { $out += self::ZERO; } // mark negativeness @@ -96,8 +335,8 @@ class ValueInfo { } $out = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); if (is_null($out) && (ValueInfo::int($value) & ValueInfo::VALID)) { - $out = abs((int) filter_var($value, \FILTER_VALIDATE_FLOAT)); - return ($out < 2) ? (bool) $out : $default; + $out = (int) filter_var($value, \FILTER_VALIDATE_FLOAT); + return ($out==1 || $out==0) ? (bool) $out : $default; } return !is_null($out) ? $out : $default; } diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index 81c3a68..7cc7871 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -32,52 +32,13 @@ abstract class AbstractHandler implements Handler { return $data; } - protected function NormalizeInput(array $data, array $types, string $dateFormat = null): array { + protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array { $out = []; - foreach ($data as $key => $value) { - if (!isset($types[$key])) { - $out[$key] = $value; - continue; - } - if (is_null($value)) { + foreach ($types as $key => $type) { + if (isset($data[$key])) { + $out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat); + } else { $out[$key] = null; - continue; - } - switch ($types[$key]) { - case "int": - if (valueInfo::int($value) & ValueInfo::VALID) { - $out[$key] = (int) $value; - } - break; - case "string": - if (is_bool($value)) { - $out[$key] = var_export($value, true); - } elseif (!is_scalar($value)) { - break; - } else { - $out[$key] = (string) $value; - } - break; - case "bool": - $test = ValueInfo::bool($value); - if (!is_null($test)) { - $out[$key] = $test; - } - break; - case "float": - $test = filter_var($value, \FILTER_VALIDATE_FLOAT); - if ($test !== false) { - $out[$key] = $test; - } - break; - case "datetime": - $t = Date::normalize($value, $dateFormat); - if ($t) { - $out[$key] = $t; - } - break; - default: - throw new Exception("typeUnknown", $types[$key]); } } return $out; diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 46a6a1c..4fefdd6 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -21,21 +21,21 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { protected $dateFormat = "unix"; protected $validInput = [ - 'name' => "string", - 'url' => "string", - 'folderId' => "int", - 'feedTitle' => "string", - 'userId' => "string", - 'feedId' => "int", - 'newestItemId' => "int", - 'batchSize' => "int", - 'offset' => "int", - 'type' => "int", - 'id' => "int", - 'getRead' => "bool", - 'oldestFirst' => "bool", - 'lastModified' => "datetime", - // 'items' => "array int", // just pass these through + 'name' => ValueInfo::T_STRING, + 'url' => ValueInfo::T_STRING, + 'folderId' => ValueInfo::T_INT, + 'feedTitle' => ValueInfo::T_STRING, + 'userId' => ValueInfo::T_STRING, + 'feedId' => ValueInfo::T_INT, + 'newestItemId' => ValueInfo::T_INT, + 'batchSize' => ValueInfo::T_INT, + 'offset' => ValueInfo::T_INT, + 'type' => ValueInfo::T_INT, + 'id' => ValueInfo::T_INT, + 'getRead' => ValueInfo::T_BOOL, + 'oldestFirst' => ValueInfo::T_BOOL, + 'lastModified' => ValueInfo::T_DATE, + 'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY, ]; public function __construct() { @@ -61,10 +61,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $data = []; } // FIXME: Do query parameters take precedence in NextCloud? Is there a conflict error when values differ? - $data = $this->normalizeInput($data, $this->validInput, "U"); - $query = $this->normalizeInput($req->query, $this->validInput, "U"); - $data = array_merge($data, $query); - unset($query); + $data = $this->normalizeInput(array_merge($data, $req->query), $this->validInput, "unix"); // check to make sure the requested function is implemented try { $func = $this->chooseCall($req->paths, $req->method); @@ -233,7 +230,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // create a folder protected function folderAdd(array $url, array $data): Response { try { - $folder = Arsse::$db->folderAdd(Arsse::$user->id, $data); + $folder = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $data['name']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // folder already exists @@ -263,13 +260,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // rename a folder (also supports moving nesting folders, but this is not a feature of the API) protected function folderRename(array $url, array $data): Response { - // there must be some change to be made - if (!sizeof($data)) { - return new Response(422); - } - // perform the edit try { - Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], $data); + Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], ['name' => $data['name']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // folder does not exist @@ -288,15 +280,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // mark all articles associated with a folder as read protected function folderMarkRead(array $url, array $data): Response { - $c = new Context; - if (isset($data['newestItemId'])) { - // if the item ID is valid (i.e. an integer), add it to the context - $c->latestEdition($data['newestItemId']); - } else { - // otherwise return an error + if (!ValueInfo::id($data['newestItemId'])) { + // if the item ID is invalid (i.e. not a positive integer), this is an error return new Response(422); } - // add the folder ID to the context + // build the context + $c = new Context; + $c->latestEdition((int) $data['newestItemId']); $c->folder((int) $url[1]); // perform the operation try { @@ -330,10 +320,6 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { return new Response(403); } - // perform an update of a single feed - if (!isset($data['feedId'])) { - return new Response(422); - } try { Arsse::$db->feedUpdate($data['feedId']); } catch (ExceptionInput $e) { @@ -351,16 +337,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // add a new feed protected function subscriptionAdd(array $url, array $data): Response { - // normalize the feed URL - if (!isset($data['url'])) { - return new Response(422); - } - // normalize the folder ID, if specified - $folder = isset($data['folderId']) ? $data['folderId'] : null; // try to add the feed $tr = Arsse::$db->begin(); try { - $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['url']); + $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, (string) $data['url']); } catch (ExceptionInput $e) { // feed already exists return new Response(409); @@ -369,9 +349,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response(422); } // if a folder was specified, move the feed to the correct folder; silently ignore errors - if ($folder) { + if ($data['folderId']) { try { - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $data['folderId']]); } catch (ExceptionInput $e) { } } @@ -416,16 +396,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // rename a feed protected function subscriptionRename(array $url, array $data): Response { - // normalize input - $in = []; - if (array_key_exists('feedTitle', $data)) { // we use array_key_exists because null is a valid input - $in['title'] = $data['feedTitle']; - } else { - return new Response(422); - } - // perform the renaming try { - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['title' => (string) $data['feedTitle']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // subscription does not exist @@ -442,16 +414,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // move a feed to a folder protected function subscriptionMove(array $url, array $data): Response { - // normalize input - $in = []; - if (isset($data['folderId'])) { - $in['folder'] = $data['folderId'] ? $data['folderId'] : null; - } else { + // if no folder is specified this is an error + if (!isset($data['folderId'])) { return new Response(422); } // perform the move try { - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['folder' => $data['folderId']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { case 10239: // subscription does not exist @@ -468,14 +437,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // mark all articles associated with a subscription as read protected function subscriptionMarkRead(array $url, array $data): Response { - $c = new Context; - if (isset($data['newestItemId'])) { - $c->latestEdition($data['newestItemId']); - } else { - // otherwise return an error + if (!ValueInfo::id($data['newestItemId'])) { + // if the item ID is invalid (i.e. not a positive integer), this is an error return new Response(422); } - // add the subscription ID to the context + // build the context + $c = new Context; + $c->latestEdition((int) $data['newestItemId']); $c->subscription((int) $url[1]); // perform the operation try { @@ -492,17 +460,17 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // set the context options supplied by the client $c = new Context; // set the batch size - if (isset($data['batchSize']) && $data['batchSize'] > 0) { + if ($data['batchSize'] > 0) { $c->limit($data['batchSize']); } // set the order of returned items - if (isset($data['oldestFirst']) && $data['oldestFirst']) { + if ($data['oldestFirst']) { $c->reverse(false); } else { $c->reverse(true); } // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one - if (isset($data['offset']) && $data['offset'] > 0) { + if ($data['offset'] > 0) { if ($c->reverse) { $c->latestEdition($data['offset'] - 1); } else { @@ -510,13 +478,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } } // set whether to only return unread - if (isset($data['getRead']) && !$data['getRead']) { + if (!ValueInfo::bool($data['getRead'], true)) { $c->unread(true); } // if no type is specified assume 3 (All) - if (!isset($data['type'])) { - $data['type'] = 3; - } + $data['type'] = $data['type'] ?? 3; switch ($data['type']) { case 0: // feed if (isset($data['id'])) { @@ -535,7 +501,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // return all items } // whether to return only updated items - if (isset($data['lastModified'])) { + if ($data['lastModified']) { $c->modifiedSince($data['lastModified']); } // perform the fetch @@ -555,14 +521,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // mark all articles as read protected function articleMarkReadAll(array $url, array $data): Response { - $c = new Context; - if (isset($data['newestItemId'])) { - // set the newest item ID as specified - $c->latestEdition($data['newestItemId']); - } else { - // otherwise return an error + if (!ValueInfo::id($data['newestItemId'])) { + // if the item ID is invalid (i.e. not a positive integer), this is an error return new Response(422); } + // build the context + $c = new Context; + $c->latestEdition((int) $data['newestItemId']); // perform the operation Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); return new Response(204); @@ -604,13 +569,9 @@ 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"); - // if the input data is not at all valid, return an error - if (!isset($data['items']) || !is_array($data['items'])) { - return new Response(422); - } // start a transaction and loop through the items $t = Arsse::$db->begin(); - $in = array_chunk($data['items'], 50); + $in = array_chunk($data['items'] ?? [], 50); for ($a = 0; $a < sizeof($in); $a++) { // initialize the matching context $c = new Context; @@ -628,13 +589,9 @@ 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"); - // if the input data is not at all valid, return an error - if (!isset($data['items']) || !is_array($data['items'])) { - return new Response(422); - } // start a transaction and loop through the items $t = Arsse::$db->begin(); - $in = array_chunk(array_column($data['items'], "guidHash"), 50); + $in = array_chunk(array_column($data['items'] ?? [], "guidHash"), 50); for ($a = 0; $a < sizeof($in); $a++) { // initialize the matching context $c = new Context; diff --git a/locale/en.php b/locale/en.php index 0539a0f..d151bee 100644 --- a/locale/en.php +++ b/locale/en.php @@ -74,6 +74,17 @@ 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', + 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, + 1 {null} + 2 {boolean} + 3 {integer} + 4 {float} + 5 {datetime} + 6 {string} + 7 {array} + other {requested type} + }', + 'Exception.JKingWeb/Arsse/ExceptionType.typeUnknown' => 'Normalization type {0} is not implemented', 'Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing' => 'Default language file "{0}" missing', 'Exception.JKingWeb/Arsse/Lang/Exception.fileMissing' => 'Language file "{0}" is not available', 'Exception.JKingWeb/Arsse/Lang/Exception.fileUnreadable' => 'Insufficient permissions to read language file "{0}"', diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index 1291b47..b886e48 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -7,6 +7,10 @@ use JKingWeb\Arsse\Test\Misc\StrClass; /** @covers \JKingWeb\Arsse\Misc\ValueInfo */ class TestValueInfo extends Test\AbstractTest { + public function setUp() { + $this->clearData(); + } + public function testGetIntegerInfo() { $tests = [ [null, I::NULL], @@ -58,9 +62,9 @@ class TestValueInfo extends Test\AbstractTest { ["no", 0], ["true", 0], ["false", 0], - [INF, 0], - [-INF, 0], - [NAN, 0], + [INF, I::FLOAT], + [-INF, I::FLOAT | I::NEG], + [NAN, I::FLOAT], [[], 0], ["some string", 0], [" ", 0], @@ -71,6 +75,10 @@ class TestValueInfo extends Test\AbstractTest { [new StrClass("-1"), I::VALID | I::NEG], [new StrClass("Msg"), 0], [new StrClass(" "), 0], + [2.5, I::FLOAT], + [0.5, I::FLOAT], + ["2.5", I::FLOAT], + ["0.5", I::FLOAT], ]; foreach ($tests as $test) { list($value, $exp) = $test; @@ -249,13 +257,13 @@ class TestValueInfo extends Test\AbstractTest { ["+000", false], ["+0.0", false], ["+000.000", false], - [-1, true], - [-1.0, true], - ["-1.0", true], - ["-001.0", true], + [-1, null], + [-1.0, null], + ["-1.0", null], + ["-001.0", null], ["-1.0e2", null], - ["-1", true], - ["-001", true], + ["-1", null], + ["-001", null], ["-1e2", null], [-0, false], ["-0", false], @@ -281,7 +289,7 @@ class TestValueInfo extends Test\AbstractTest { [new StrClass(""), false], [new StrClass("1"), true], [new StrClass("0"), false], - [new StrClass("-1"), true], + [new StrClass("-1"), null], [new StrClass("Msg"), null], [new StrClass(" "), null], ]; @@ -294,4 +302,215 @@ class TestValueInfo extends Test\AbstractTest { } } } + + public function testNormalizeValues() { + $tests = [ + /* The test data are very dense for this set. Each value is normalized to each of the following types: + + - mixed (no normalization performed) + - null + - boolean + - integer + - float + - string + - array + + 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 */ + [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]], + [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX,true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false]], + [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false]], + ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false]], + ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false]], + ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false]], + ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false]], + ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false]], + ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false]], + ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false]], + ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false]], + ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false]], + ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false]], + ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false]], + ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false]], + [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false]], + ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false]], + ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false]], + [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false]], + ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false]], + ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false]], + ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false]], + ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false]], + ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false]], + ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false]], + [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false]], + [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false]], + ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false]], + ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false]], + ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false]], + ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false]], + ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false]], + ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false]], + [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false]], + ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false]], + ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false]], + [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false]], + ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false]], + ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false]], + [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false]], + [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false]], + ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false]], + ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false]], + ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false]], + ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false]], + ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false]], + ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false]], + [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false]], + [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false]], + [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false]], + [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true] ], + ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false]], + [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false]], + [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false]], + [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false]], + [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false]], + [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false]], + [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false]], + [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false]], + [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false]], + [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false]], + [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false]], + ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false]], + ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false]], + [$this->d("2010-01-01T00:00:00",0,0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,0)],false]], + [$this->d("2010-01-01T00:00:00",0,1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,1)],false]], + [$this->d("2010-01-01T00:00:00",1,0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,0)],false]], + [$this->d("2010-01-01T00:00:00",1,1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,1)],false]], + [1e14, [null,true], [true, false], [100000000000000,true], [1e14, true], ["100000000000000", true], [[1e14], false]], + [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false]], + [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true] ], + [['a'=>1,'b'=>2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a'=>1,'b'=>2], true] ], + [new Test\Result([['a'=>1,'b'=>2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a'=>1,'b'=>2]], true] ], + ]; + $params = [ + [I::T_MIXED, "Mixed" ], + [I::T_NULL, "Null", ], + [I::T_BOOL, "Boolean", ], + [I::T_INT, "Integer", ], + [I::T_FLOAT, "Floating point"], + [I::T_STRING, "String", ], + [I::T_ARRAY, "Array", ], + ]; + foreach ($params as $index => $param) { + list($type, $name) = $param; + $this->assertNull(I::normalize(null, $type | I::M_STRICT | I::M_NULL), $name." null-passthrough test failed"); + foreach ($tests as $test) { + list($exp, $pass) = $index ? $test[$index] : [$test[$index], true]; + $value = $test[0]; + $assert = (is_float($exp) && is_nan($exp) ? "assertNan" : (is_scalar($exp) ? "assertSame" : "assertEquals")); + $this->$assert($exp, I::normalize($value, $type), $name." test failed for value: ".var_export($value, true)); + if ($pass) { + $this->$assert($exp, I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $this->$assert($exp, I::normalize($value, $type | I::M_STRICT), $name." error test failed for value: ".var_export($value, true)); + } else { + $this->assertNull(I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $exc = new ExceptionType("strictFailure", $type); + try { + $act = I::normalize($value, $type | I::M_STRICT); + } catch (ExceptionType $e) { + $act = $e; + } finally { + $this->assertEquals($exc, $act, $name." error test failed for value: ".var_export($value, true)); + } + } + } + } + // DateTimeInterface tests + $tests = [ + /* 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), ], + [$this->d("2010-01-01T00:00:00",1,0), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [$this->d("2010-01-01T00:00:00",1,1), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [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->t(1262304000), ], + [1262304000.123456, $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), ], + [1262304000.42, $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), ], + ["0.12345600 1262304000", $this->t(1262304000.123456), null, null, null, null, null, null, null, null, null, null, ], + ["0.42 1262304000", null, null, null, null, null, null, null, null, null, null, null, ], + ["2010-01-01T00:00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00Z", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-05:00", null, $this->t(1262322000), $this->t(1262322000), null, null, null, null, null, null, null, $this->t(1262322000), ], + ["2010-01-01T00:00:00.123456Z", null, null, $this->t(1262304000.123456), null, null, null, null, null, null, null, $this->t(1262304000.123456), ], + ["Fri, 01 Jan 2010 00:00:00 GMT", null, null, null, $this->t(1262304000), null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01 00:00:00", null, null, null, null, $this->t(1262304000), null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01", null, null, null, null, null, $this->t(1262304000), null, null, null, null, $this->t(1262304000), ], + ["12:34:56", null, null, null, null, null, null, $this->t(45296), null, null, null, $this->t(strtotime("today")+45296), ], + ["1262304000", null, null, null, null, null, null, null, $this->t(1262304000), null, null, null, ], + ["1262304000.123456", null, null, null, null, null, null, null, null, $this->t(1262304000.123456), null, null, ], + ["1262304000.42", null, null, null, null, null, null, null, null, $this->t(1262304000.42), null, null, ], + ["Jan 1, 2010 (Fri)", null, null, null, null, null, null, null, null, null, $this->t(1262304000), null, ], + ["First day of Jan 2010 12AM", null, null, null, null, null, null, null, null, null, null, $this->t(1262304000), ], + ]; + $formats = [ + "microtime", + "iso8601", + "iso8601m", + "http", + "sql", + "date", + "time", + "unix", + "float", + "!M j, Y (D)", + null, + ]; + $exc = new ExceptionType("strictFailure", I::T_DATE); + foreach ($formats as $index => $format) { + foreach ($tests as $test) { + $value = $test[0]; + $exp = $test[$index+1]; + $this->assertEquals($exp, I::normalize($value, I::T_DATE, $format), "Test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + $this->assertEquals($exp, I::normalize($value, I::T_DATE | I::M_DROP, $format), "Drop test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + // test for exception in case of errors + $exp = $exp ?? $exc; + try { + $act = I::normalize($value, I::T_DATE | I::M_STRICT, $format); + } catch (ExceptionType $e) { + $act = $e; + } finally { + $this->assertEquals($exp, $act, "Error test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + } + } + } + // Array-mode tests + $tests = [ + [I::T_INT | I::M_DROP, new Test\Result([1, 2, 2.2, 3]), [1,2,null,3] ], + [I::T_INT, new Test\Result([1, 2, 2.2, 3]), [1,2,2,3] ], + [I::T_STRING | I::M_STRICT, "Bare string", ["Bare string"]], + ]; + foreach ($tests as $index => $test) { + list($type, $value, $exp) = $test; + $this->assertEquals($exp, I::normalize($value, $type | I::M_ARRAY, "iso8601"), "Failed test #$index"); + } + } + + protected function d($spec, $local, $immutable): \DateTimeInterface { + $tz = $local ? new \DateTimeZone("America/Toronto") : new \DateTimeZone("UTC"); + if ($immutable) { + return \DateTimeImmutable::createFromFormat("!Y-m-d\TH:i:s", $spec, $tz); + } else { + return \DateTime::createFromFormat("!Y-m-d\TH:i:s", $spec, $tz); + } + } + + protected function t(float $spec): \DateTime { + return \DateTime::createFromFormat("U.u", sprintf("%F", $spec), new \DateTimeZone("UTC")); + } } diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 813ef9d..53aaba3 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -388,6 +388,7 @@ class TestNCNV1_2 extends Test\AbstractTest { ['id' => 2, 'name' => "Hardware", 'parent' => null], ]; // set of various mocks for testing + Phake::when(Arsse::$db)->folderAdd($this->anything(), $this->anything())->thenThrow(new \Exception); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0])->thenReturn(1)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1)->thenReturn($out[0]); @@ -499,6 +500,7 @@ class TestNCNV1_2 extends Test\AbstractTest { // set up the necessary mocks Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.com/news.atom")->thenReturn(2112)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.org/news.atom")->thenReturn(42)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call + Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", new \PicoFeed\Reader\SubscriptionNotFoundException)); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->feeds['db'][0]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->feeds['db'][1]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->feeds['db'][2]); @@ -584,7 +586,7 @@ class TestNCNV1_2 extends Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => ""]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); - $exp = new Response(204); + $exp = new Response(422); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[0]), 'application/json'))); $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[1]), 'application/json'))); @@ -628,7 +630,7 @@ class TestNCNV1_2 extends Test\AbstractTest { ]; Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->feedUpdate(-1)->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $exp = new Response(404); @@ -788,7 +790,7 @@ class TestNCNV1_2 extends Test\AbstractTest { 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(422); + $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"))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple"))); @@ -797,14 +799,12 @@ class TestNCNV1_2 extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]), 'application/json'))); - $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]), 'application/json'))); - $exp = new Response(204); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]), 'application/json'))); @@ -812,13 +812,13 @@ 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)->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); + 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)->articleMark(Arsse::$user->id, $unread, (new Context)->editions([])); - Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[0])); + 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])); From 488a7bddac298f016480b05a002f67e35b5915ad Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 20 Oct 2017 09:54:08 -0400 Subject: [PATCH 27/66] Adapt TTRSS to new type system --- lib/REST/TinyTinyRSS/API.php | 192 +++++++++------------ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 19 +- 2 files changed, 100 insertions(+), 111 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a0136e3..0d6726a 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; +use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; @@ -33,46 +34,47 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const VERSION = "17.4"; const LABEL_OFFSET = 1024; const VALID_INPUT = [ - 'op' => "str", - 'sid' => "str", - 'user' => "str", - 'password' => "str", - 'include_empty' => "bool", - 'unread_only' => "bool", - 'enable_nested' => "bool", - 'caption' => "str", - 'parent_id' => "int", - 'category_id' => "int", - 'feed_url' => "str", - 'login' => "str", - 'feed_id' => "int", - 'article_id' => "int", - 'label_id' => "int", - 'article_ids' => "str", - 'assign' => "bool", - 'is_cat' => "bool", - 'cat_id' => "int", - 'limit' => "int", - 'offset' => "int", - 'include_nested' => "bool", - 'skip' => "int", - 'filter' => "str", - 'show_excerpt' => "bool", - 'show_content' => "bool", - 'view_mode' => "str", - 'include_attachments' => "bool", - 'since_id' => "int", - 'order_by' => "str", - 'sanitize' => "bool", - 'force_update' => "bool", - 'has_sandbox' => "bool", - 'include_header' => "bool", - 'search' => "str", - 'search_mode' => "str", - 'match_on' => "str", - 'mode' => "int", - 'field' => "int", - 'data' => "str", + 'op' => ValueInfo::T_STRING, + 'sid' => ValueInfo::T_STRING, + 'seq' => ValueInfo::T_INT, + 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'parent_id' => ValueInfo::T_INT, + 'category_id' => ValueInfo::T_INT, + 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'feed_id' => ValueInfo::T_INT, + 'article_id' => ValueInfo::T_INT, + 'label_id' => ValueInfo::T_INT, + 'article_ids' => ValueInfo::T_STRING, + 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'cat_id' => ValueInfo::T_INT, + 'limit' => ValueInfo::T_INT, + 'offset' => ValueInfo::T_INT, + 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'skip' => ValueInfo::T_INT, + 'filter' => ValueInfo::T_STRING, + 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'view_mode' => ValueInfo::T_STRING, + 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'since_id' => ValueInfo::T_INT, + 'order_by' => ValueInfo::T_STRING, + 'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'force_update' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'search' => ValueInfo::T_STRING, + 'search_mode' => ValueInfo::T_STRING, + 'match_on' => ValueInfo::T_STRING, + 'mode' => ValueInfo::T_INT, + 'field' => ValueInfo::T_INT, + 'data' => ValueInfo::T_STRING, ]; const FATAL_ERR = [ 'seq' => null, @@ -98,16 +100,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // non-JSON input indicates an error return new Response(400, self::FATAL_ERR); } - // layer input over defaults - $data = array_merge([ - 'seq' => 0, - 'op' => "", - 'sid' => null, - ], $data); 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($data['sid']); + $this->resumeSession((string) $data['sid']); } $method = "op".ucfirst($data['op']); if (!method_exists($this, $method)) { @@ -142,10 +145,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } - protected function resumeSession($id): bool { + protected function resumeSession(string $id): bool { try { // verify the supplied session is valid - $s = Arsse::$db->sessionResume((string) $id); + $s = Arsse::$db->sessionResume($id); } catch (\JKingWeb\Arsse\User\ExceptionSession $e) { // if not throw an exception throw new Exception("NOT_LOGGED_IN"); @@ -167,7 +170,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opLogin(array $data): array { - if (isset($data['user']) && isset($data['password']) && Arsse::$user->auth($data['user'], $data['password'])) { + if (Arsse::$user->auth((string) $data['user'], (string) $data['password'])) { $id = Arsse::$db->sessionCreate($data['user']); return [ 'session_id' => $id, @@ -281,9 +284,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetCategories(array $data): array { // normalize input - $all = isset($data['include_empty']) ? ValueInfo::bool($data['include_empty'], false) : false; - $read = !(isset($data['unread_only']) ? ValueInfo::bool($data['unread_only'], false) : false); - $deep = !(isset($data['enable_nested']) ? ValueInfo::bool($data['enable_nested'], false) : false); + $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(); @@ -356,12 +359,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opAddCategory(array $data) { $in = [ - 'name' => isset($data['caption']) ? $data['caption'] : "", - 'parent' => isset($data['parent_id']) ? $data['parent_id'] : null, + 'name' => $data['caption'], + 'parent' => $data['parent_id'], ]; - if (!$in['parent']) { - $in['parent'] = null; - } try { return Arsse::$db->folderAdd(Arsse::$user->id, $in); } catch (ExceptionInput $e) { @@ -384,7 +384,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRemoveCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id'])) { + if (!ValueInfo::id($data['category_id'])) { // if the folder is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } @@ -398,7 +398,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opMoveCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['parent_id']) || !ValueInfo::id($data['parent_id'], true)) { + 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"); } @@ -415,21 +415,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRenameCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['caption'])) { - // if the folder is invalid, throw an error - throw new Exception("INCORRECT_USAGE"); - } $info = ValueInfo::str($data['caption']); - if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { - // if the folder name is invalid, throw an error + 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' => (string) $data['caption'], + 'name' => $data['caption'], ]; try { // try to rename the folder - Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -453,23 +449,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opSubscribeToFeed(array $data): array { - if (!isset($data['feed_url']) || !(ValueInfo::str($data['feed_url']) & ValueInfo::VALID)) { - // if the feed URL is invalid, throw an error + 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"); } - // normalize input data - if ( - (isset($data['category_id']) && !ValueInfo::id($data['category_id'], true)) || - (isset($data['login']) && !(ValueInfo::str($data['login']) & ValueInfo::VALID)) || - (isset($data['password']) && !(ValueInfo::str($data['password']) & ValueInfo::VALID)) - ) { - // if the category is not a valid ID or the feed username or password are not convertible to strings, also throw an error - throw new Exception("INCORRECT_USAGE"); - } $url = (string) $data['feed_url']; - $folder = isset($data['category_id']) ? (int) $data['category_id'] : null; - $fetchUser = isset($data['login']) ? (string) $data['login'] : ""; - $fetchPassword = isset($data['password']) ? (string) $data['password'] : ""; + $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 { @@ -519,10 +506,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opUnsubscribeFeed(array $data): array { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { - // if the feed is invalid, throw an error - throw new Exception("FEED_NOT_FOUND"); - } try { // attempt to remove the feed Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']); @@ -533,16 +516,16 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opMoveFeed(array $data) { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) { + 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' => (int) $data['category_id'], + 'folder' => $data['category_id'], ]; try { // try to move the feed - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -550,21 +533,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRenameFeed(array $data) { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['caption'])) { - // if the feed is invalid or there is no caption, throw an error - throw new Exception("INCORRECT_USAGE"); - } $info = ValueInfo::str($data['caption']); - if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { - // if the feed name is invalid, throw an error + 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' => (string) $data['caption'], + 'name' => $data['caption'], ]; try { // try to rename the feed - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -577,7 +556,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { throw new Exception("INCORRECT_USAGE"); } try { - Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $data['feed_id'])['feed']); + Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']); } catch(ExceptionInput $e) { throw new Exception("FEED_NOT_FOUND"); } @@ -597,7 +576,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetLabels(array $data): array { // this function doesn't complain about invalid article IDs - $article = (isset($data['article_id']) && ValueInfo::id($data['article_id'])) ? (int) $data['article_id'] : 0; + $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0; try { $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : []; } catch (ExceptionInput $e) { @@ -618,7 +597,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opAddLabel(array $data) { $in = [ - 'name' => isset($data['caption']) ? $data['caption'] : "", + 'name' => (string) $data['caption'], ]; try { return $this->labelOut(Arsse::$db->labelAdd(Arsse::$user->id, $in)); @@ -635,7 +614,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opRemoveLabel(array $data) { // normalize the label ID; missing or invalid IDs are rejected - $id = $this->labelIn(isset($data['label_id']) ? $data['label_id'] : 0); + $id = $this->labelIn($data['label_id']); try { // attempt to remove the label Arsse::$db->labelRemove(Arsse::$user->id, $id); @@ -647,8 +626,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opRenameLabel(array $data) { // normalize input; missing or invalid IDs are rejected - $id = $this->labelIn(isset($data['label_id']) ? $data['label_id'] : 0); - $name = isset($data['caption']) ? $data['caption'] : ""; + $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]); @@ -662,12 +641,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opSetArticleLabel(array $data): array { - if (!isset($data['article_ids']) || !isset($data['label_id'])) { + if (!$data['article_ids'] || !$data['label_id']) { throw new Exception("INCORRECT_USAGE"); } $label = $this->labelIn($data['label_id']); - $articles = explode(",", (string) $data['article_ids']); - $assign = ValueInfo::bool(isset($data['assign']) ? $data['assign'] : null, false); - + $articles = explode(",", $data['article_ids']); + $assign = $data['assign'] ?? false; } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 81e6df4..8b83713 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -257,11 +257,21 @@ class TestTinyTinyAPI extends Test\AbstractTest { [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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); @@ -272,14 +282,14 @@ class TestTinyTinyAPI extends Test\AbstractTest { $exp = $this->respGood(); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); - Phake::verify(Arsse::$db, Phake::times(4))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } public function testRenameACategory() { @@ -404,7 +414,8 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"], ]; - Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + 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"]); @@ -415,7 +426,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - Phake::verify(Arsse::$db, Phake::times(3))->subscriptionRemove(Arsse::$user->id, $this->anything()); + Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); } public function testMoveASubscription() { From d05aaf688fc95431f6f812fc7a0742923b1fc27e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 20 Oct 2017 18:17:47 -0400 Subject: [PATCH 28/66] Preliminary TTRSS setArticleLabel implementation --- lib/Database.php | 8 ++++---- lib/REST/TinyTinyRSS/API.php | 16 +++++++++++++--- tests/REST/NextCloudNews/TestNCNV1_2.php | 16 ++++++++-------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index f8ef9da..e738dfd 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -948,7 +948,7 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } - public function articleMark(string $user, array $data, Context $context = null): bool { + 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]); } @@ -1008,7 +1008,7 @@ class Database { } // commit the transaction $tr->commit(); - return (bool) $out; + return $out; } public function articleStarred(string $user): array { @@ -1253,7 +1253,7 @@ class Database { } } - public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): bool { + 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]); } @@ -1291,7 +1291,7 @@ class Database { } // commit the transaction $tr->commit(); - return (bool) $out; + return $out; } protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 0d6726a..dc440de 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -75,6 +75,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'mode' => ValueInfo::T_INT, 'field' => ValueInfo::T_INT, 'data' => ValueInfo::T_STRING, + 'pref_name' => ValueInfo::T_STRING, ]; const FATAL_ERR = [ 'seq' => null, @@ -641,11 +642,20 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opSetArticleLabel(array $data): array { - if (!$data['article_ids'] || !$data['label_id']) { - throw new Exception("INCORRECT_USAGE"); - } $label = $this->labelIn($data['label_id']); $articles = explode(",", $data['article_ids']); $assign = $data['assign'] ?? false; + $out = 0; + $in = array_chunk($data['article_ids'], 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]; } } diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 53aaba3..f72f10d 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -703,7 +703,7 @@ class TestNCNV1_2 extends Test\AbstractTest { 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'))); @@ -718,7 +718,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'))); @@ -733,7 +733,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"))); @@ -747,13 +747,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"))); @@ -785,7 +785,7 @@ 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 From 5c4772d95a6a7d5b2afedad58fc8392b2fd9a21e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 20 Oct 2017 19:02:42 -0400 Subject: [PATCH 29/66] CS fixes --- .php_cs.dist | 2 +- lib/Database.php | 6 +- lib/Misc/ValueInfo.php | 6 +- lib/REST/TinyTinyRSS/API.php | 26 +-- tests/Misc/TestValueInfo.php | 208 ++++++++++----------- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 4 +- tests/bootstrap.php | 2 +- 7 files changed, 127 insertions(+), 127 deletions(-) diff --git a/.php_cs.dist b/.php_cs.dist index f2e2fc2..1410543 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -1,7 +1,7 @@ __FUNCTION__, "user" => $user]); } // check to make sure the parent exists, if one is specified - $parent = $this->folderValidateId($user, $parent)['id']; + $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT id,name,parent, @@ -1268,8 +1268,8 @@ class Database { $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"], + "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(); diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 09dca6d..cc83e39 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -134,7 +134,7 @@ class ValueInfo { return $out->format(Date::FORMAT['iso8601'][1]); } elseif (is_float($value) && is_finite($value)) { $out = (string) $value; - if(!strpos($out, "E")) { + if (!strpos($out, "E")) { return $out; } else { $out = sprintf("%F", $value); @@ -174,7 +174,7 @@ class ValueInfo { if ($dateFormat=="microtime") { // PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float if (preg_match("<^0\.\d{6}00 \d+$>", $value)) { - $value = substr($value,11).".".substr($value,2,6); + $value = substr($value, 11).".".substr($value, 2, 6); } else { throw new \Exception; } @@ -251,7 +251,7 @@ class ValueInfo { return self::NULL; } // interpret the value as a float - $float = filter_var($value, \FILTER_VALIDATE_FLOAT); + $float = filter_var($value, \FILTER_VALIDATE_FLOAT); if ($float !== false) { if (!fmod($float, 1)) { // an integral float is acceptable diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index dc440de..8fac3a1 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -106,7 +106,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { $data['seq'] = isset($data['seq']) ? $data['seq'] : 0; $data = $this->normalizeInput($data, self::VALID_INPUT, "unix"); - } catch(ExceptionType $e) { + } catch (ExceptionType $e) { throw new Exception("INCORRECT_USAGE"); } if (strtolower((string) $data['op']) != "login") { @@ -392,7 +392,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // attempt to remove the folder Arsse::$db->folderRemove(Arsse::$user->id, (int) $data['category_id']); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -409,7 +409,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // try to move the folder Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -427,7 +427,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // try to rename the folder Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -436,7 +436,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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 + case 10502: // invalid URL return ['code' => 2, 'message' => $e->getMessage()]; case 10521: // no feeds discovered return ['code' => 3, 'message' => $e->getMessage()]; @@ -486,7 +486,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // 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) { + } catch (FeedException $e) { // feed errors (handled above) return $this->feedError($e); } @@ -510,7 +510,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // attempt to remove the feed Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { throw new Exception("FEED_NOT_FOUND"); } return ['status' => "OK"]; @@ -527,7 +527,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // try to move the feed Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -545,7 +545,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // try to rename the feed Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -558,7 +558,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } try { Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { throw new Exception("FEED_NOT_FOUND"); } return ['status' => "OK"]; @@ -592,7 +592,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'bg_color' => "", 'checked' => in_array($l['id'], $list), ]; - } + } return $out; } @@ -619,7 +619,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // attempt to remove the label Arsse::$db->labelRemove(Arsse::$user->id, $id); - } catch(ExceptionInput $e) { + } catch (ExceptionInput $e) { // ignore all errors } return null; @@ -632,7 +632,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { // try to rename the folder Arsse::$db->labelPropertiesSet(Arsse::$user->id, $id, ['name' => $name]); - } catch(ExceptionInput $e) { + } 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"); diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index b886e48..e5dc5e3 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -315,85 +315,85 @@ class TestValueInfo extends Test\AbstractTest { - string - array - For each of these types, there is an expected output value, as well as a boolean indicating whether + 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 */ - [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]], - [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX,true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false]], - [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false]], - ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false]], - ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false]], - ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false]], - ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false]], - ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false]], - ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false]], - ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false]], - ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false]], - ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false]], - ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false]], - ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false]], - ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false]], - [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false]], - ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false]], - ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false]], - [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false]], - ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false]], - ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false]], - ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false]], - ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false]], - ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false]], - ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false]], - [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false]], - [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false]], - ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false]], - ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false]], - ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false]], - ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false]], - ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false]], - ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false]], - [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false]], - ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false]], - ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false]], - [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false]], - ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false]], - ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false]], - [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false]], - [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false]], - ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false]], - ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false]], - ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false]], - ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false]], - ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false]], - ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false]], - [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false]], - [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false]], - [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false]], - [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true] ], - ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false]], - [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false]], - [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false]], - [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false]], - [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false]], - [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false]], - [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false]], - [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false]], - [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false]], - [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false]], - [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false]], - ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false]], - ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false]], - [$this->d("2010-01-01T00:00:00",0,0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,0)],false]], - [$this->d("2010-01-01T00:00:00",0,1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,1)],false]], - [$this->d("2010-01-01T00:00:00",1,0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,0)],false]], - [$this->d("2010-01-01T00:00:00",1,1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,1)],false]], - [1e14, [null,true], [true, false], [100000000000000,true], [1e14, true], ["100000000000000", true], [[1e14], false]], - [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false]], - [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true] ], - [['a'=>1,'b'=>2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a'=>1,'b'=>2], true] ], - [new Test\Result([['a'=>1,'b'=>2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a'=>1,'b'=>2]], true] ], + /* 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]], + [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX,true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false]], + [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false]], + ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false]], + ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false]], + ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false]], + ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false]], + ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false]], + ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false]], + ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false]], + ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false]], + ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false]], + ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false]], + ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false]], + ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false]], + [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false]], + ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false]], + ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false]], + [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false]], + ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false]], + ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false]], + ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false]], + ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false]], + ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false]], + ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false]], + [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false]], + [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false]], + ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false]], + ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false]], + ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false]], + ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false]], + ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false]], + ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false]], + [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false]], + ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false]], + ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false]], + [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false]], + ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false]], + ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false]], + [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false]], + [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false]], + ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false]], + ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false]], + ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false]], + ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false]], + ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false]], + ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false]], + [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false]], + [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false]], + [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false]], + [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true] ], + ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false]], + [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false]], + [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false]], + [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false]], + [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false]], + [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false]], + [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false]], + [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false]], + [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false]], + [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false]], + [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false]], + ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false]], + ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false]], + [$this->d("2010-01-01T00:00:00", 0, 0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 0)],false]], + [$this->d("2010-01-01T00:00:00", 0, 1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 0, 1)],false]], + [$this->d("2010-01-01T00:00:00", 1, 0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 0)],false]], + [$this->d("2010-01-01T00:00:00", 1, 1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00", 1, 1)],false]], + [1e14, [null,true], [true, false], [100000000000000,true], [1e14, true], ["100000000000000", true], [[1e14], false]], + [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false]], + [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true] ], + [['a'=>1,'b'=>2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a'=>1,'b'=>2], true] ], + [new Test\Result([['a'=>1,'b'=>2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a'=>1,'b'=>2]], true] ], ]; $params = [ [I::T_MIXED, "Mixed" ], @@ -430,33 +430,33 @@ 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) */ - [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), ], - [$this->d("2010-01-01T00:00:00",1,0), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], - [$this->d("2010-01-01T00:00:00",1,1), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], - [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->t(1262304000), ], - [1262304000.123456, $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), ], - [1262304000.42, $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), ], - ["0.12345600 1262304000", $this->t(1262304000.123456), null, null, null, null, null, null, null, null, null, null, ], - ["0.42 1262304000", null, null, null, null, null, null, null, null, null, null, null, ], - ["2010-01-01T00:00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01T00:00:00Z", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01T00:00:00+0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01T00:00:00-0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01T00:00:00+00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01T00:00:00-05:00", null, $this->t(1262322000), $this->t(1262322000), null, null, null, null, null, null, null, $this->t(1262322000), ], - ["2010-01-01T00:00:00.123456Z", null, null, $this->t(1262304000.123456), null, null, null, null, null, null, null, $this->t(1262304000.123456), ], - ["Fri, 01 Jan 2010 00:00:00 GMT", null, null, null, $this->t(1262304000), null, null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01 00:00:00", null, null, null, null, $this->t(1262304000), null, null, null, null, null, $this->t(1262304000), ], - ["2010-01-01", null, null, null, null, null, $this->t(1262304000), null, null, null, null, $this->t(1262304000), ], - ["12:34:56", null, null, null, null, null, null, $this->t(45296), null, null, null, $this->t(strtotime("today")+45296), ], - ["1262304000", null, null, null, null, null, null, null, $this->t(1262304000), null, null, null, ], - ["1262304000.123456", null, null, null, null, null, null, null, null, $this->t(1262304000.123456), null, null, ], - ["1262304000.42", null, null, null, null, null, null, null, null, $this->t(1262304000.42), null, null, ], - ["Jan 1, 2010 (Fri)", null, null, null, null, null, null, null, null, null, $this->t(1262304000), null, ], - ["First day of Jan 2010 12AM", null, null, null, null, null, null, null, null, null, null, $this->t(1262304000), ], + /* 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), ], + [$this->d("2010-01-01T00:00:00", 1, 0), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [$this->d("2010-01-01T00:00:00", 1, 1), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [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->t(1262304000), ], + [1262304000.123456, $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), ], + [1262304000.42, $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), ], + ["0.12345600 1262304000", $this->t(1262304000.123456), null, null, null, null, null, null, null, null, null, null, ], + ["0.42 1262304000", null, null, null, null, null, null, null, null, null, null, null, ], + ["2010-01-01T00:00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00Z", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-05:00", null, $this->t(1262322000), $this->t(1262322000), null, null, null, null, null, null, null, $this->t(1262322000), ], + ["2010-01-01T00:00:00.123456Z", null, null, $this->t(1262304000.123456), null, null, null, null, null, null, null, $this->t(1262304000.123456), ], + ["Fri, 01 Jan 2010 00:00:00 GMT", null, null, null, $this->t(1262304000), null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01 00:00:00", null, null, null, null, $this->t(1262304000), null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01", null, null, null, null, null, $this->t(1262304000), null, null, null, null, $this->t(1262304000), ], + ["12:34:56", null, null, null, null, null, null, $this->t(45296), null, null, null, $this->t(strtotime("today")+45296), ], + ["1262304000", null, null, null, null, null, null, null, $this->t(1262304000), null, null, null, ], + ["1262304000.123456", null, null, null, null, null, null, null, null, $this->t(1262304000.123456), null, null, ], + ["1262304000.42", null, null, null, null, null, null, null, null, $this->t(1262304000.42), null, null, ], + ["Jan 1, 2010 (Fri)", null, null, null, null, null, null, null, null, null, $this->t(1262304000), null, ], + ["First day of Jan 2010 12AM", null, null, null, null, null, null, null, null, null, null, $this->t(1262304000), ], ]; $formats = [ "microtime", diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 8b83713..822473c 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -12,7 +12,7 @@ use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; use Phake; -/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API +/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API * @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */ class TestTinyTinyAPI extends Test\AbstractTest { protected $h; @@ -522,7 +522,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in)))); } - public function testRetrieveTheServerConfiguration () { + public function testRetrieveTheServerConfiguration() { $in = ['op' => "getConfig", 'sid' => "PriestsOfSyrinx"]; $interval = Service::interval(); $valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c74e551..66f69a8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,4 +5,4 @@ namespace JKingWeb\Arsse; const NS_BASE = __NAMESPACE__."\\"; define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); -require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; \ No newline at end of file +require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; From 6a700f784c07c782dace71bf9540914a30b4a0b7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 28 Oct 2017 10:52:38 -0400 Subject: [PATCH 30/66] Tests for TTRSS operation setArticleLabel; fixes #90 --- lib/REST/TinyTinyRSS/API.php | 4 +-- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 38 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8fac3a1..8ea00e5 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -643,10 +643,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opSetArticleLabel(array $data): array { $label = $this->labelIn($data['label_id']); - $articles = explode(",", $data['article_ids']); + $articles = explode(",", (string) $data['article_ids']); $assign = $data['assign'] ?? false; $out = 0; - $in = array_chunk($data['article_ids'], 50); + $in = array_chunk($articles, 50); for ($a = 0; $a < sizeof($in); $a++) { // initialize the matching context $c = new Context; diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 822473c..e208c8c 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -824,4 +824,42 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertResponse($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($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' => 89]); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + } } From a94c1e85926b1659f00c16bd33f2bc8526df6336 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 30 Oct 2017 09:40:36 -0400 Subject: [PATCH 31/66] Add static images used by getFeedTree TTRSS' getFeedTree operation references the URLs of icons included with the software. These icons are from the Silk icon set (see README in this commit), which is compatible with our license, so we simply use them as well. --- www/tt-rss/images/README | 21 +++++++++++++++++++++ www/tt-rss/images/archive.png | Bin 0 -> 555 bytes www/tt-rss/images/feed.png | Bin 0 -> 691 bytes www/tt-rss/images/folder.png | Bin 0 -> 537 bytes www/tt-rss/images/fresh.png | Bin 0 -> 633 bytes www/tt-rss/images/label.png | Bin 0 -> 586 bytes www/tt-rss/images/time.png | Bin 0 -> 793 bytes 7 files changed, 21 insertions(+) create mode 100644 www/tt-rss/images/README create mode 100644 www/tt-rss/images/archive.png create mode 100644 www/tt-rss/images/feed.png create mode 100644 www/tt-rss/images/folder.png create mode 100644 www/tt-rss/images/fresh.png create mode 100644 www/tt-rss/images/label.png create mode 100644 www/tt-rss/images/time.png diff --git a/www/tt-rss/images/README b/www/tt-rss/images/README new file mode 100644 index 0000000..86d8d68 --- /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 0000000000000000000000000000000000000000..8443c23eb944cf8ef49c9d13cd496502f46f1885 GIT binary patch literal 555 zcmV+`0@VG9P)i3lOYrtSl@<#7b-w zf}j{s!5HvocfT|9z82@(O@vrwU^wRt=bd>tXQpGD!`Kvuv@XEI8~tgUP2L`{+*)U@I@ zrVtr5X14??iAF(=0+k>q)v`Scm$9&=i`*knBsnaUVL1>ti*O1xfzmiD$%Md-h*6M( z@*iB)icu3eU424Ok{kp%Y!1dvp%f0`ac9vcupx^$vU0xuKpJcBvej0UYk%)EV>mIx2hV}QRf#LX^Uh(%`7hZ~|KEf#uQ31s002ovPDHLkV1hgQ{`mj^ literal 0 HcmV?d00001 diff --git a/www/tt-rss/images/feed.png b/www/tt-rss/images/feed.png new file mode 100644 index 0000000000000000000000000000000000000000..315c4f4fa62cb720326ba3f54259666ba3999e42 GIT binary patch literal 691 zcmV;k0!;mhP)bpQb1=l6TxbDZwj&S={?7%qx-u`rsG(Zp`-rh=e^=%((1yvsuf5d=&62Zj)Y zH&JviNS_F4_Hj|T(1j4$p-!}kixP9&dB4uv^MveG?dGf%sUCoc2!IFxD6wHRA2^dX zXRVk!-qSfk(jcaUKn#RP48(whfPlJUpApdrA!TQi_4D+fVoM;3I0gZ8{=Xv~Po;geVA+Em9@0Wq2 zr>OTZEGR05L=gf1T;ucCxq6Q6EgJiH@@-lVaAlQyw`jIF^c=&IVnj|95hHbE_cnt| zTzZQ?F4Ne@(bH(~&3nM%m)I@ID{@jJ2qZPjr)jhpe9hViOwH5k&|T#EmmL3(vHeUQ zq^!t^Al6JD;=mHq^Bg?J-8-zG2Od7gZbknG;K9czYjPqG*xjPo0k(c4%lPXTpw(qq z@aGMnxtFS(np+2kC} z7P02O874ZkJH$v#nCUVx$({yDN`IX@o2wyvTD#e`qN`_w5<}$3F+_x(K@^6+>g^d@v4;gkbWsEoXE%32*i1tcpTNXd5CcIl)ECgqz|2rE6EW}s7R?kl za1q`0GCkMruC6-2LANtwVlsgzsp4?{@7$`KBv!G66>Vie3h?3OmEEkjwdLG0PgLVi z`!N((f$A@n17Ldj#`};0I3@iHJ5M{#IZz|UIYRm4(!uV7eYIYIwQf&}_2J~}>pQ^n z6o8--^T(=hkBNQ_k{-_GWE;FMW7!p}f{NG3nHZ{D5<3d8&tLh%a4AqqnjMkr3m&fkMdECD3N5}Unig5wy40;>lo4j~k+e}v)` zR6)J8Mk*u=SpB`p6o)7j?S0T@9?bz#m@l>gc*zk__|*!FMcHwP!gwLJvS~9c0px8E zWDAD_Iw@kR+3LLZX?3 zyhGkEvUzKYKpu0v_u>?yY~y}#nAbh$p6}dm6adQqK)FzXa=E-qP)ntfS|}9Md_I4Y z%jHh8*{qt$WYlyzt)^0`-M<7XmC7CwTlIP!6`0meW$j(o%*9*fiupyDJZx7&n@&K>z*>QCDhTzTp zTlnzwp?s_A3C!nn*-|JJ`k^?T&hB6^z;e07X0ws&YPAxuvwwlMjasdSU@%xy91e#I zzZYAh^udB=a16G~?^>-EC_Ew4q~Q1a4;7oucH0!-LK+*Y)he3JCK`a7P}Mc?vlj7AM;VkzMEhOwnj`hl1MBj6s4Nlz_+^kH@of;eX>V_G&2` Tt35or00000NkvXXu0mjf&s-qj literal 0 HcmV?d00001 diff --git a/www/tt-rss/images/label.png b/www/tt-rss/images/label.png new file mode 100644 index 0000000000000000000000000000000000000000..83d12924ff3847904f13ce02fe7d96ee1a6012c7 GIT binary patch literal 586 zcmV-Q0=4~#P)QiaQF5iW`b6E^(ZmMn^kdOo-E`onH8J z>I^oDOD^i7tLp#iuVX0<1_Si_eRR8BbUGch+ikR3Ei{`=G#ZU70Mez9dc7VT$2p4m zd@dl>YV{&SqRAvsRaHz@6vc&5j=MmqR5}Zh$TBt4G)mO#_2y=?fn`}DPryp0f_y%I z5+c#mT_YF2aLcM;7%2yjikWQx$AQIn^t=DVOWSS;S{So@FPhj^qFdn`O&<--0OvnY|@wf|-YPH%sz9Z8# zO*CzvI(X)jneq*W`tt}f9*@ZdB9VxI2uitJmidlswOYMG{`5KIBMjws55m8-gs2aP zLvn#oD0JoSQYaK;z9U;Mm&cHYA7FRacp6>9u`S^unM@)O2)sRbQNEv?&1TtRvDkxr zfPVJ{f2X&2F*f^RkGsNtWHK2!l}a(X#8*fU&-Y*9ScWV1`~4pG5le8ujv*W^`bq(deZ3B7h`EB*FHYdKr%;k=xO&(k^EfNlSiKZ>5l+xr|%SFOV@6-ysFmD2F5 ze93OiS+LaQym;|2f6tbH%~V`D+ND?vc>4J^KSLxEMifJQ`8>*~y^+pGr&o-n=LJ zGWB(yB#;DR8&Lhqi{0(#wc#SwSB~jZKzIFx`8od>2Fo-Pfe7*M8^q#qw2yTxiXzd~ zRaz|F*rr78G`JZXG*YX~5K@5k>G@0HdlBo-6v1jHye?%qRwO@+-hO7J^4LlPG>@A1#{ zQFl4x7tnG)+cz_2Mq_f*H!U)kgg{iHqxT)Yr3ec@K!`)z_%h1c0Y2Eu(dMPkrhq5v zY+bKWfx|sTiOEB71HuwS*CDzA%ReBv4*7Zy7RM0n6`5#chfOJK`ze)Y6>d6Z?UmyNHH!3DdsP-ARyDo}1HO+>7_um7 zx_gj{+_aU_OUH_~Jd?KI#ICZujD`of2mDpCv_zFGE%6}tfL|j!WGu_i-u>4%{%d{$ X7`zMSfT21V00000NkvXXu0mjfkBx0` literal 0 HcmV?d00001 From 579551f5fd14e451aac80953aa88f5fa9bc698f0 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 30 Oct 2017 13:11:27 -0400 Subject: [PATCH 32/66] Implement TTRSS operation getFeedTree; fixes #94 --- lib/Database.php | 2 +- lib/REST/TinyTinyRSS/API.php | 177 ++++++++++++++++++++- locale/en.php | 9 +- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 49 ++++-- 4 files changed, 218 insertions(+), 19 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 11142f6..16b2ab2 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1165,7 +1165,7 @@ class Database { 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 >= ? + FROM arsse_labels where owner is ? and articles >= ? order by name ", "str", "int" )->run($user, !$includeEmpty); } diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8ea00e5..5bca289 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -21,10 +21,12 @@ Protocol difference so far: - Handling of incorrect Content-Type and/or HTTP method is different - TT-RSS accepts whitespace-only names; we do not - TT-RSS allows two folders to share the same name under the same parent; we do not - - Session lifetime is much shorter by default (does TT-RSS even expire sessions?) + - Session lifetime is much shorter by default - Categories and feeds will always be sorted alphabetically (the protocol does not allow for clients to re-order) - The "Archived" virtual feed is non-functional (the protocol does not allow archiving) - The "Published" virtual feed is non-functional (this will not be implemented in the near term) + - setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result + - The result of setArticleLabel counts only records which actually changed rather than all entries attempted */ @@ -283,6 +285,179 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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:-1", + 'bare_id' => -1, + 'type' => "category", + 'unread' => 0, + 'items' => [ + array_merge([ // All articles + 'name' => Arsse::$lang->msg("API.TTRSS.Feed.All"), + 'id' => "FEED:-4", + 'bare_id' => -4, + '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:-3", + 'bare_id' => -3, + '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:-1", + 'bare_id' => -1, + '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:-2", + 'bare_id' => -2, + '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:0", + 'bare_id' => 0, + '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:-6", + 'bare_id' => -6, + '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:-2", + 'bare_id' => -2, + '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:0", + 'bare_id' => 0, + '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, + ]; + } + 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'], + '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; diff --git a/locale/en.php b/locale/en.php index d151bee..12f3707 100644 --- a/locale/en.php +++ b/locale/en.php @@ -2,7 +2,14 @@ return [ 'API.TTRSS.Category.Uncategorized' => 'Uncategorized', 'API.TTRSS.Category.Special' => 'Special', - 'API.TTRSS.Category.Labels' => 'Labels', + '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)', diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index e208c8c..211a71a 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -30,17 +30,17 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], ]; protected $subscriptions = [ - ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'favicon' => 'http://example.com/6.png'], - ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'favicon' => 'http://example.com/3.png'], - ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'favicon' => null], - ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'favicon' => 'http://example.com/2.png'], - ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'favicon' => ''], - ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'favicon' => 'http://example.com/4.png'], + ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', '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', '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', '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', 'favicon' => null], + ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'favicon' => ''], + ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'favicon' => 'http://example.com/2.png'], ]; protected $labels = [ - ['id' => 5, 'articles' => 0, 'read' => 0, 'name' => "Interesting"], - ['id' => 3, 'articles' => 100, 'read' => 94, 'name' => "Fascinating"], - ['id' => 1, 'articles' => 2, 'read' => 0, 'name' => "Logical"], + ['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' => 0, 'unread' => 2, 'name' => "Logical"], ]; protected $usedLabels = [ ['id' => 3, 'articles' => 100, 'read' => 94, 'name' => "Fascinating"], @@ -766,10 +766,10 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => -1027, 'counter' => 6, 'auxcounter' => 100], ['id' => -1025, 'counter' => 2, 'auxcounter' => 2], ['id' => 3, 'has_img' => 1, 'counter' => 2, 'updated' => "2016-05-23T06:40:02"], + ['id' => 4, 'has_img' => 1, 'counter' => 6, 'updated' => "2017-10-09T15:58:34"], ['id' => 1, 'has_img' => 0, 'counter' => 5, 'updated' => "2017-09-15T22:54:16"], - ['id' => 2, 'has_img' => 1, 'counter' => 10, 'updated' => "2011-11-11T11:11:11"], ['id' => 5, 'has_img' => 0, 'counter' => 12, 'updated' => "2017-07-07T17:07:17"], - ['id' => 4, 'has_img' => 1, 'counter' => 6, 'updated' => "2017-10-09T15:58:34"], + ['id' => 2, 'has_img' => 1, 'counter' => 10, 'updated' => "2011-11-11T11:11:11"], ['id' => 5, 'kind' => "cat", 'counter' => 10], ['id' => 6, 'kind' => "cat", 'counter' => 18], ['id' => 3, 'kind' => "cat", 'counter' => 28], @@ -795,29 +795,29 @@ class TestTinyTinyAPI extends Test\AbstractTest { Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 4)->thenThrow(new ExceptionInput("idMissing")); $exp = [ [ - ['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' => -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' => true], ], [ - ['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' => 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' => -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++) { @@ -862,4 +862,21 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($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(['total' => 10, 'unread' => 4, 'read' => 6]); + // 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'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>8,'bare_id'=>-2,],['id'=>'CAT:4','bare_id'=>4,'auxcounter'=>0,'name'=>'Photography','items'=>[],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(0 feeds)',],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; + $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>8,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; + $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + } } From f543f084c1cb440af3d8d3aa6954b5fd32142acd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 30 Oct 2017 14:50:31 -0400 Subject: [PATCH 33/66] Build and Nginx changes for TTRSS --- build.xml | 1 + dist/nginx.conf | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/build.xml b/build.xml index 32f5a6c..a2912bd 100644 --- a/build.xml +++ b/build.xml @@ -8,6 +8,7 @@ + diff --git a/dist/nginx.conf b/dist/nginx.conf index 160eec9..94cff1c 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; + } } \ No newline at end of file From e4ae3ca6ea15c5f0b2465a9dbdfc78873bff4b8d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 30 Oct 2017 16:18:09 -0400 Subject: [PATCH 34/66] Use constants for TTRSS' special feeds and categories --- lib/REST/TinyTinyRSS/API.php | 91 +++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 5bca289..f2e6058 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -35,6 +35,20 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; const LABEL_OFFSET = 1024; + // special feeds + const FEED_ARCHIVED = 0; + const FEED_STARRED = -1; + const FEED_PUBLISHED = -2; + const FEED_FRESH = -3; + const FEED_ALL = -4; + const FEED_READ = -6; + // special categories + const CAT_UNCATEGORIZED = 0; + const CAT_SPECIAL = -1; + const CAT_LABELS = -2; + const CAT_NOT_SPECIAL = -3; + const CAT_ALL = -4; + // valid input const VALID_INPUT = [ 'op' => ValueInfo::T_STRING, 'sid' => ValueInfo::T_STRING, @@ -79,6 +93,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'data' => ValueInfo::T_STRING, 'pref_name' => ValueInfo::T_STRING, ]; + // generic error construct const FATAL_ERR = [ 'seq' => null, 'status' => 1, @@ -228,10 +243,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $categories[$a]['counter'] = 0; } // add the "Uncategorized" and "Labels" virtual categories to the list - $catmap[0] = sizeof($categories); - $categories[] = ['id' => 0, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'parent' => 0, 'children' => 0, 'counter' => 0]; - $catmap[-2] = sizeof($categories); - $categories[] = ['id' => -2, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'parent' => 0, 'children' => 0, 'counter' => 0]; + $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']) { @@ -249,7 +264,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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[-2]]['counter'] += $unread; + $categories[$catmap[self::CAT_LABELS]]['counter'] += $unread; } // do a second pass on categories, summing descendant unread counts for ancestors, pruning categories with no unread, and building a final category list $cats = []; @@ -274,13 +289,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // 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' => 0, 'counter' => 0, 'auxcounter' => 0], // Archived articles - ['id' => -1, 'counter' => $starred['unread'], 'auxcounter' => $starred['total']], // Starred articles - ['id' => -2, 'counter' => 0, 'auxcounter' => 0], // Published articles - ['id' => -3, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles - ['id' => -4, 'counter' => $countAll, 'auxcounter' => 0], // All articles + ['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); } @@ -301,50 +316,50 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // start with the special feeds $out[] = [ 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), - 'id' => "CAT:-1", - 'bare_id' => -1, + '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:-4", - 'bare_id' => -4, + '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:-3", - 'bare_id' => -3, + '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:-1", - 'bare_id' => -1, + '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:-2", - 'bare_id' => -2, + '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:0", - 'bare_id' => 0, + '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:-6", - 'bare_id' => -6, + '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), @@ -374,8 +389,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if ($items) { $out[] = [ 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), - 'id' => "CAT:-2", - 'bare_id' => -2, + 'id' => "CAT:".self::CAT_LABELS, + 'bare_id' => self::CAT_LABELS, 'type' => "category", 'unread' => $unread, 'items' => $items, @@ -392,8 +407,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if ($items || !$all) { $out[] = [ 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), - 'id' => "CAT:0", - 'bare_id' => 0, + 'id' => "CAT:".self::CAT_UNCATEGORIZED, + 'bare_id' => self::CAT_UNCATEGORIZED, 'type' => "category", 'auxcounter' => 0, 'unread' => 0, @@ -473,12 +488,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $cats[$a]['order'] = $a + 1; } // add the "Uncategorized", "Special", and "Labels" virtual categories to the list - $map[0] = sizeof($cats); - $cats[] = ['id' => 0, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'children' => 0, 'unread' => 0, 'feeds' => 0]; - $map[-1] = sizeof($cats); - $cats[] = ['id' => -1, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), 'children' => 0, 'unread' => 0, 'feeds' => 6]; - $map[-2] = sizeof($cats); - $cats[] = ['id' => -2, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'children' => 0, 'unread' => 0, 'feeds' => 0]; + $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) { @@ -491,14 +506,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // 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[-2]; + $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[-1]; + $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) { From 2a08edb27d168c4065b5292fd78e44dde08dd488 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 30 Oct 2017 23:18:43 -0400 Subject: [PATCH 35/66] Implement TTRSS catchupFeed operation; fixes #88 --- lib/REST/TinyTinyRSS/API.php | 104 ++++++++++++++++++++- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 66 +++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index f2e6058..fe54ba2 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -27,6 +27,7 @@ Protocol difference so far: - The "Published" virtual feed is non-functional (this will not be implemented in the near term) - setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result - The result of setArticleLabel counts only records which actually changed rather than all entries attempted + - Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent */ @@ -439,6 +440,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { '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; @@ -459,7 +461,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'name' => $c['name'], 'id' => "CAT:".$c['id'], 'bare_id' => $c['id'], - 'parent_id' => $c['parent'], + '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, @@ -754,9 +756,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return ['status' => "OK"]; } - protected function labelIn($id): int { + protected function labelIn($id, bool $throw = true): int { if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) { - throw new Exception("INCORRECT_USAGE"); + if ($throw) { + throw new Exception("INCORRECT_USAGE"); + } else { + return 0; + } } return (abs($id) - self::LABEL_OFFSET); } @@ -848,4 +854,96 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } 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, whereas some valid contexts are special cases + $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 is a special case + try { + $tr = Arsse::$db->begin(); + // filter the subscription list to return only uncategorized, and get their IDs + $list = array_column(array_filter(Arsse::$db->subscriptionList(Arsse::$user->id)->getAll(), function($value) {return is_null($value['folder']);}), "id"); + // perform marking for each applicable subscription + foreach ($list as $id) { + Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->subscription($id)); + } + $tr->commit(); + } catch (ExceptionInput $e) { + // ignore errors + } + return $out; + case self::CAT_LABELS: + // this is also a special case + try { + $tr = Arsse::$db->begin(); + // list all non-empty labels + $list = array_column(Arsse::$db->labelList(Arsse::$user->id, false)->getAll(), "id"); + // perform marking for each label + foreach ($list as $id) { + try { + Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id)); + } catch (ExceptionInput $e) { + // ignore errors + } + } + $tr->commit(); + } catch (ExceptionInput $e) { + // ignore errors + } + return $out; + 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; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 211a71a..dcae6b0 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -879,4 +879,70 @@ class TestTinyTinyAPI extends Test\AbstractTest { $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>8,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($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], + ]; + $in3 = [ + // complex context + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true], + ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], + ]; + $in4 = [ + // this one has a tricky time-based context and will be handled last + ['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->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($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)); + // reset the database mock + $this->setUp(); + Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + // verify the complex contexts + for ($a = 0; $a < sizeof($in3); $a++) { + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in3[$a]))), "Test $a failed"); + } + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(6)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(3)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1)); + Phake::verify(Arsse::$db, Phake::times(3))->articleMark; + // verify the time-based mock + $t = Date::sub("PT24H"); + for ($a = 0; $a < sizeof($in4); $a++) { + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in4[$a]))), "Test $a failed"); + } + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); + } } From f22fe8ba95bf38c6cc15cff9f6487f090a274e08 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 31 Oct 2017 09:39:06 -0400 Subject: [PATCH 36/66] Tweak catchupFeed --- lib/REST/TinyTinyRSS/API.php | 8 ++------ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index fe54ba2..b74e442 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -65,7 +65,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, 'feed_id' => ValueInfo::T_INT, - 'article_id' => ValueInfo::T_INT, + 'article_id' => ValueInfo::T_MIXED, // single integer or comma-separated list in getArticle 'label_id' => ValueInfo::T_INT, 'article_ids' => ValueInfo::T_STRING, 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, @@ -891,11 +891,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $list = array_column(Arsse::$db->labelList(Arsse::$user->id, false)->getAll(), "id"); // perform marking for each label foreach ($list as $id) { - try { - Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id)); - } catch (ExceptionInput $e) { - // ignore errors - } + Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id)); } $tr->commit(); } catch (ExceptionInput $e) { diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index dcae6b0..19555f0 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -905,7 +905,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], ]; $in4 = [ - // this one has a tricky time-based context and will be handled last + // this one has a tricky time-based context ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], ]; Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation")); @@ -926,7 +926,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42)); // reset the database mock $this->setUp(); - Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleMark->thenReturn(42); Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->labels)); Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); From cbe82c57cd0d11d1d2207db81e2d193919d8538c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 31 Oct 2017 18:09:16 -0400 Subject: [PATCH 37/66] Allow subscriptions to be listed non-recursively This is used in multiple TTRSS operations (at least catchupFeed and getFeeds, and so is a useful optimization --- lib/Database.php | 11 +++++++---- tests/lib/Database/SeriesSubscription.php | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 16b2ab2..f661e14 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -498,7 +498,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]); } @@ -527,11 +527,14 @@ 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) { - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree + } 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()); } @@ -577,7 +580,7 @@ class Database { } // 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]); diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 3a5a159..f25def9 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -257,6 +257,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", @@ -269,6 +284,7 @@ trait SeriesSubscription { ], ]; $this->assertResult($exp, Arsse::$db->subscriptionList($this->user, 2)); + } public function testListSubscriptionsInAMissingFolder() { From fff6082e3cd9382ceaba99d111bbd2faa65722d3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 31 Oct 2017 18:12:50 -0400 Subject: [PATCH 38/66] Adjust TTRSS catchupFeed to use shallow subscription listing --- lib/REST/TinyTinyRSS/API.php | 13 +++++++------ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index b74e442..5193848 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -19,8 +19,9 @@ use JKingWeb\Arsse\REST\Response; Protocol difference so far: - Handling of incorrect Content-Type and/or HTTP method is different - - TT-RSS accepts whitespace-only names; we do not + - TT-RSS accepts whitespace-only names for categories, labels, and feeds; we do not - TT-RSS allows two folders to share the same name under the same parent; we do not + - TT-RSS requires the user to choose in the face of multiple found feeds during discovery; we use the first one (picoFeed limitation) - Session lifetime is much shorter by default - Categories and feeds will always be sorted alphabetically (the protocol does not allow for clients to re-order) - The "Archived" virtual feed is non-functional (the protocol does not allow archiving) @@ -873,14 +874,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { $tr = Arsse::$db->begin(); // filter the subscription list to return only uncategorized, and get their IDs - $list = array_column(array_filter(Arsse::$db->subscriptionList(Arsse::$user->id)->getAll(), function($value) {return is_null($value['folder']);}), "id"); + $list = array_column(Arsse::$db->subscriptionList(Arsse::$user->id, null, false)->getAll(), "id"); // perform marking for each applicable subscription foreach ($list as $id) { Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->subscription($id)); } $tr->commit(); - } catch (ExceptionInput $e) { - // ignore errors + } catch (ExceptionInput $e) { // @codeCoverageIgnore + // ignore errors; none should occur } return $out; case self::CAT_LABELS: @@ -894,8 +895,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id)); } $tr->commit(); - } catch (ExceptionInput $e) { - // ignore errors + } catch (ExceptionInput $e) { // @codeCoverageIgnore + // ignore errors; none should occur } return $out; default: diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 19555f0..f10912b 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -927,7 +927,8 @@ class TestTinyTinyAPI extends Test\AbstractTest { // reset the database mock $this->setUp(); Phake::when(Arsse::$db)->articleMark->thenReturn(42); - Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->subscriptions)); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result(array_filter($this->subscriptions, function($value) {return is_null($value['folder']);}))); Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->labels)); Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); // verify the complex contexts From 9c9c34f7fe9e8d704d9867c8e16b87f697258a15 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 Nov 2017 17:17:46 -0400 Subject: [PATCH 39/66] Implement TTRSS operation getFeeds; fixes #80 --- lib/REST/TinyTinyRSS/API.php | 166 +++++++++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 199 ++++++++++++++++++--- 2 files changed, 343 insertions(+), 22 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 5193848..360e770 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -626,6 +626,172 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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' => $l['unread'], + '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' => $starred, + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $published) { + $out[] = [ + 'id' => self::FEED_PUBLISHED, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Published"), + 'unread' => $published, + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $fresh) { + $out[] = [ + 'id' => self::FEED_FRESH, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"), + 'unread' => $fresh, + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $global) { + $out[] = [ + 'id' => self::FEED_ALL, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.All"), + 'unread' => $global, + '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 + 'cat_id' => self::CAT_SPECIAL, + ]; + } + if (!$unread || $archived) { + $out[] = [ + 'id' => self::FEED_ARCHIVED, + 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"), + 'unread' => $archived, + '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()) { diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index f10912b..1012d24 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -30,22 +30,23 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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', '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', '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', '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', 'favicon' => null], - ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'favicon' => ''], - ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'favicon' => 'http://example.com/2.png'], + ['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' => 0, 'unread' => 2, 'name' => "Logical"], + ['id' => 1, 'articles' => 2, 'read' => 2, 'unread' => 0, 'name' => "Logical"], ]; protected $usedLabels = [ - ['id' => 3, 'articles' => 100, 'read' => 94, 'name' => "Fascinating"], - ['id' => 1, 'articles' => 2, 'read' => 0, 'name' => "Logical"], + ['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 function respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -690,7 +691,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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(['total' => 10, 'unread' => 4, 'read' => 6]); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); $exp = [ [ ['id' => 5, 'title' => "Local", 'unread' => 10, 'order_id' => 1], @@ -701,7 +702,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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' => 8], + ['id' => -2, 'title' => "Labels", 'unread' => 6], ], [ ['id' => 5, 'title' => "Local", 'unread' => 10, 'order_id' => 1], @@ -711,7 +712,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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' => 8], + ['id' => -2, 'title' => "Labels", 'unread' => 6], ], [ ['id' => 5, 'title' => "Local", 'unread' => 10, 'order_id' => 1], @@ -719,7 +720,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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' => 8], + ['id' => -2, 'title' => "Labels", 'unread' => 6], ], [ ['id' => 4, 'title' => "Photography", 'unread' => 0, 'order_id' => 1], @@ -727,20 +728,20 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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' => 8], + ['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' => 8], + ['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' => 8], + ['id' => -2, 'title' => "Labels", 'unread' => 6], ], ]; for ($a = 0; $a < sizeof($in); $a++) { @@ -754,7 +755,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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(['total' => 10, 'unread' => 4, 'read' => 6]); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); $exp = [ ['id' => "global-unread", 'counter' => 35], ['id' => "subscribed-feeds", 'counter' => 6], @@ -764,7 +765,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => -3, 'counter' => 7, 'auxcounter' => 0], ['id' => -4, 'counter' => 35, 'auxcounter' => 0], ['id' => -1027, 'counter' => 6, 'auxcounter' => 100], - ['id' => -1025, 'counter' => 2, 'auxcounter' => 2], + ['id' => -1025, 'counter' => 0, 'auxcounter' => 2], ['id' => 3, 'has_img' => 1, 'counter' => 2, 'updated' => "2016-05-23T06:40:02"], ['id' => 4, 'has_img' => 1, 'counter' => 6, 'updated' => "2017-10-09T15:58:34"], ['id' => 1, 'has_img' => 0, 'counter' => 5, 'updated' => "2017-09-15T22:54:16"], @@ -775,7 +776,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => 3, 'kind' => "cat", 'counter' => 28], ['id' => 2, 'kind' => "cat", 'counter' => 5], ['id' => 1, 'kind' => "cat", 'counter' => 7], - ['id' => -2, 'kind' => "cat", 'counter' => 8], + ['id' => -2, 'kind' => "cat", 'counter' => 6], ]; $this->assertResponse($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in)))); } @@ -872,11 +873,11 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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(['total' => 10, 'unread' => 4, 'read' => 6]); + 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'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>8,'bare_id'=>-2,],['id'=>'CAT:4','bare_id'=>4,'auxcounter'=>0,'name'=>'Photography','items'=>[],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(0 feeds)',],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; + $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:4','bare_id'=>4,'auxcounter'=>0,'name'=>'Photography','items'=>[],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(0 feeds)',],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); - $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>8,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; + $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); } @@ -946,4 +947,158 @@ class TestTinyTinyAPI extends Test\AbstractTest { } 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->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + $this->assertResponse($this->respGood([]), $this->h->dispatch(new Request("POST", "", json_encode($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; + } } From 42a5ccb96c70f749cf09c70d749acf57f5c3f09b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 6 Nov 2017 23:32:29 -0500 Subject: [PATCH 40/66] Handle request splitting in data model rather than controllers Queries for multiple specific articles are limited in size because of limits on the number of bound query parameters. Currently this limit is somewhat arbitrarily set at 50, but it may increase. Historically controllers would be responsible for chunking input, but this will present problems when the expected output is a result set, and of course the maintenance burden increases as the number of controllers increases. This commit transfers the burden to the data model, and consequently introduces a ResultAggregate class which collects chunked result sets (currently only for articleList). In the course of making these changes the mock Result class was also largely rewritten, fixing many bugs with it. This commit does not modify the controllers nor their tests; this will be done in a subsequent commit. --- lib/Database.php | 214 +++++++++++++-------- lib/Db/AbstractResult.php | 10 +- lib/Db/ResultAggregate.php | 46 +++++ lib/Db/SQLite3/Result.php | 1 - tests/Db/TestResultAggregate.php | 101 ++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 2 +- tests/lib/Database/SeriesArticle.php | 9 +- tests/lib/Result.php | 25 +-- tests/phpunit.xml | 1 + 9 files changed, 310 insertions(+), 99 deletions(-) create mode 100644 lib/Db/ResultAggregate.php create mode 100644 tests/Db/TestResultAggregate.php diff --git a/lib/Database.php b/lib/Database.php index f661e14..ceff7ff 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -11,6 +11,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { const SCHEMA_VERSION = 2; + const LIMIT_ARTICLES = 50; /** @var Db\Driver */ public $db; @@ -855,8 +856,8 @@ class Database { // 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 + } 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)", @@ -869,8 +870,8 @@ class Database { // 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 + } 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)", @@ -917,27 +918,62 @@ class Database { return $q; } + 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): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - $columns = [ - "arsse_articles.url as url", - "title", - "author", - "content", - "guid", - "published as published_date", - "edited as edited_date", - "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", - "arsse_enclosures.url as media_url", - "arsse_enclosures.type as media_type", - ]; - $q = $this->articleQuery($user, $context, $columns); - $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()); + // 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); + } + $tr->commit(); + return new Db\ResultAggregate(...$out); + } else { + $columns = [ + "arsse_articles.url as url", + "title", + "author", + "content", + "guid", + "published as published_date", + "edited as edited_date", + "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", + "arsse_enclosures.url as media_url", + "arsse_enclosures.type as media_type", + ]; + $q = $this->articleQuery($user, $context, $columns); + $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 { @@ -945,10 +981,21 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - $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(); + // 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 { @@ -956,62 +1003,73 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $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; + // 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); } - } 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 = $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)", "SELECT ?,?", ["bool","bool"], $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(); + $tr->commit(); + return $out; + } else { + // 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; + } + } 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 = $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)", "SELECT ?,?", ["bool","bool"], $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(); + } + // commit the transaction + $tr->commit(); + return $out; } - // commit the transaction - $tr->commit(); - return $out; } public function articleStarred(string $user): array { diff --git a/lib/Db/AbstractResult.php b/lib/Db/AbstractResult.php index dce2926..59592f9 100644 --- a/lib/Db/AbstractResult.php +++ b/lib/Db/AbstractResult.php @@ -9,17 +9,19 @@ 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; } + $this->next(); return null; } public function getRow() { + $out = ($this->valid() ? $this->cur : null); $this->next(); - return ($this->valid() ? $this->cur : null); + return $out; } public function getAll(): array { diff --git a/lib/Db/ResultAggregate.php b/lib/Db/ResultAggregate.php new file mode 100644 index 0000000..2244bd9 --- /dev/null +++ b/lib/Db/ResultAggregate.php @@ -0,0 +1,46 @@ +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/SQLite3/Result.php b/lib/Db/SQLite3/Result.php index 9aed7a9..d0fdacd 100644 --- a/lib/Db/SQLite3/Result.php +++ b/lib/Db/SQLite3/Result.php @@ -7,7 +7,6 @@ use JKingWeb\Arsse\Db\Exception; class Result extends \JKingWeb\Arsse\Db\AbstractResult { protected $st; protected $set; - protected $pos = 0; protected $cur = null; protected $rows = 0; protected $id = 0; diff --git a/tests/Db/TestResultAggregate.php b/tests/Db/TestResultAggregate.php new file mode 100644 index 0000000..433ef42 --- /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/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 1012d24..d92df3e 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -929,7 +929,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->setUp(); Phake::when(Arsse::$db)->articleMark->thenReturn(42); Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result(array_filter($this->subscriptions, function($value) {return is_null($value['folder']);}))); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->filterSubs(null))); Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->labels)); Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); // verify the complex contexts diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 83c4d80..9731f4f 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -2,6 +2,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; @@ -346,6 +347,7 @@ 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)); @@ -610,8 +612,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() { @@ -676,8 +677,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() { @@ -769,6 +769,7 @@ trait SeriesArticle { $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 testCountArticlesWithoutAuthority() { diff --git a/tests/lib/Result.php b/tests/lib/Result.php index 3bf3316..6c44d82 100644 --- a/tests/lib/Result.php +++ b/tests/lib/Result.php @@ -13,21 +13,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() { @@ -52,22 +55,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 f12fe40..68213eb 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -47,6 +47,7 @@
Db/TestTransaction.php + Db/TestResultAggregate.php Db/SQLite3/TestDbResultSQLite3.php Db/SQLite3/TestDbStatementSQLite3.php Db/SQLite3/TestDbDriverCreationSQLite3.php From c6cd8b8aaa6d2446b9910c1d047f793ecd6920a1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 7 Nov 2017 10:00:31 -0500 Subject: [PATCH 41/66] Removing request chunking from NCN controller --- lib/REST/NextCloudNews/V1_2.php | 36 ++++++++---------------- tests/REST/NextCloudNews/TestNCNV1_2.php | 36 ++++++++---------------- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 4fefdd6..fb95079 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -569,19 +569,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); } @@ -589,19 +583,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/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index f72f10d..79bdcdd 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -776,8 +776,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++) { @@ -787,9 +785,7 @@ class TestNCNV1_2 extends Test\AbstractTest { } 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"))); @@ -812,27 +808,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() { From 6b1cd3816d7a2d99ed225c34919c452b3e75035e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 8 Nov 2017 11:31:44 -0500 Subject: [PATCH 42/66] Add warnings for slow tests in PHPUnit This should help in fixing slow tests down the road. --- composer.json | 5 +- composer.lock | 147 +++++++++++++++++++++++++++++++--------------- tests/phpunit.xml | 8 +++ 3 files changed, 111 insertions(+), 49 deletions(-) diff --git a/composer.json b/composer.json index 552c15d..5ac1e73 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,8 @@ } ], + "minimum-stability": "dev", + "prefer-stable": true, "require": { "php": "^7.0", "ext-intl": "*", @@ -36,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 597efa5..d6e1294 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": "1193e4106b6c84c545e6091560214ad5", + "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", @@ -192,16 +192,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "2.0.6", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa" + "reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/34a7699e6f31b1ef4035ee36444407cecf9f56aa", - "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b", + "reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b", "shasum": "" }, "require": { @@ -280,7 +280,7 @@ "x.509", "x509" ], - "time": "2017-06-05T06:31:10+00:00" + "time": "2017-10-23T05:04:54+00:00" }, { "name": "zendframework/zendxml", @@ -760,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": { @@ -780,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" @@ -798,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.", @@ -841,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", @@ -1136,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": { @@ -1215,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", @@ -1452,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", @@ -1490,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", @@ -2489,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": { @@ -2507,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", @@ -2549,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", @@ -5539,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/tests/phpunit.xml b/tests/phpunit.xml index 68213eb..0cc5d78 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -18,6 +18,14 @@ + + + + 1500 + 1000 + + + From ea986f50327e6675329cd5f076c2d9aecf8685b9 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 9 Nov 2017 14:21:12 -0500 Subject: [PATCH 43/66] Implement TTRSS operation updateArticle; fixes #83 This required adding the "notes" column to the arsse_marks table and adding same as a target value in Database::articleMark() The Context class was also adjusted to remove the possibility of false positives in some tests --- lib/Database.php | 13 ++- lib/Misc/Context.php | 4 +- lib/REST/TinyTinyRSS/API.php | 60 ++++++++++ sql/SQLite3/1.sql | 14 +++ tests/Misc/TestValueInfo.php | 1 + tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 81 ++++++++++++++ tests/lib/Database/SeriesArticle.php | 124 ++++++++++++--------- 7 files changed, 238 insertions(+), 59 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ceff7ff..87a7738 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1017,6 +1017,7 @@ class Database { $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 = [ @@ -1024,17 +1025,19 @@ class Database { 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))", - "INSERT INTO arsse_marks(subscription,article,read,starred) + 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) - from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1)" + 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 @@ -1060,7 +1063,7 @@ class Database { "((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)", "SELECT ?,?", ["bool","bool"], $values); + $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); diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index b1864f1..a4fdd8c 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -13,8 +13,8 @@ class Context { public $subscription; public $oldestEdition; public $latestEdition; - public $unread = false; - public $starred = false; + public $unread = null; + public $starred = null; public $modifiedSince; public $notModifiedSince; public $edition; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 360e770..8390c50 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1109,4 +1109,64 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // 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]; + } } diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 94970ba..cb7563d 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -25,6 +25,20 @@ create table arsse_label_members ( 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/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index e5dc5e3..6957b3a 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -79,6 +79,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; diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index d92df3e..c66c727 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -1101,4 +1101,85 @@ class TestTinyTinyAPI extends Test\AbstractTest { $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' => 4], // 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->assertEquals($out[$a], $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + } + } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 9731f4f..ab67768 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -190,21 +190,22 @@ 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,'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','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'], ] ], 'arsse_labels' => [ @@ -331,7 +332,7 @@ trait SeriesArticle { ]; 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"; } @@ -434,10 +435,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); } @@ -460,10 +461,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); } @@ -492,10 +493,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); } @@ -510,10 +511,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); } @@ -528,10 +529,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); } @@ -539,10 +559,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); } @@ -550,8 +570,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); } @@ -564,8 +584,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); } @@ -589,7 +609,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); } @@ -602,7 +622,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); } @@ -635,7 +655,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); } @@ -667,7 +687,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); } @@ -732,10 +752,10 @@ 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); } @@ -754,8 +774,8 @@ trait SeriesArticle { Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notModifiedSince('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); } From ea08bbb87be805b66c9fd19cc648b283b90c0551 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 10 Nov 2017 12:02:59 -0500 Subject: [PATCH 44/66] Implement TTRSS feed icons; fixes #121 This introduces a data model function of unusual privilege: it can retrieve favicon URLs for any subscription, regardless of user ID. This is a single-purpose hack and its use should be avoided if at all possible. --- lib/Database.php | 4 ++ lib/REST.php | 13 +++-- lib/REST/TinyTinyRSS/Icon.php | 32 ++++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyIcon.php | 54 +++++++++++++++++++++ tests/lib/Database/SeriesSubscription.php | 23 +++++++-- tests/phpunit.xml | 1 + 6 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 lib/REST/TinyTinyRSS/Icon.php create mode 100644 tests/REST/TinyTinyRSS/TestTinyTinyIcon.php diff --git a/lib/Database.php b/lib/Database.php index 87a7738..59f4a42 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -629,6 +629,10 @@ class Database { 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", 'type' => "int > 0"]); diff --git a/lib/REST.php b/lib/REST.php index c340e37..ddee9d3 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -21,14 +21,19 @@ class REST { '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 - // Fever https://feedafever.com/api // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html + // Fever https://feedafever.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: diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php new file mode 100644 index 0000000..9c03d58 --- /dev/null +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -0,0 +1,32 @@ +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/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php new file mode 100644 index 0000000..d542740 --- /dev/null +++ b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php @@ -0,0 +1,54 @@ + */ +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/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index f25def9..f91bee8 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -44,6 +44,7 @@ trait SeriesSubscription { 'username' => "str", 'password' => "str", 'next_fetch' => "datetime", + 'favicon' => "str", ], 'rows' => [] // filled in the series setup ], @@ -104,9 +105,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); @@ -402,4 +403,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/phpunit.xml b/tests/phpunit.xml index 0cc5d78..9d65e7b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -78,6 +78,7 @@ REST/NextCloudNews/TestNCNVersionDiscovery.php REST/NextCloudNews/TestNCNV1_2.php REST/TinyTinyRSS/TestTinyTinyAPI.php + REST/TinyTinyRSS/TestTinyTinyIcon.php Service/TestService.php From e83c6949b8dcc206f2d9880ad6ea3aa7ff2a26d7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 13 Nov 2017 23:29:25 -0500 Subject: [PATCH 45/66] Cleanup --- tests/REST/TinyTinyRSS/TestTinyTinyIcon.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php index d542740..76da6c6 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php @@ -4,12 +4,6 @@ namespace JKingWeb\Arsse; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Response; -use JKingWeb\Arsse\Test\Result; -use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; -use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\Db\Transaction; -use JKingWeb\Arsse\REST\TinyTinyRSS\API; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon */ From de92fb514bce817d62d25058d86d0ede2f1ffd67 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 15 Nov 2017 15:38:49 -0500 Subject: [PATCH 46/66] Implement TTRSS opera getArticle; fixes #84 --- lib/Database.php | 5 +- lib/REST/TinyTinyRSS/API.php | 67 +++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 132 ++++++++++++++++++++- tests/lib/Database/SeriesArticle.php | 72 ++++++----- 4 files changed, 244 insertions(+), 32 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 59f4a42..82a09e2 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -962,8 +962,10 @@ class Database { return new Db\ResultAggregate(...$out); } else { $columns = [ + // (id, subscription, feed, modified, unread, starred, edition): always included "arsse_articles.url as url", - "title", + "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", "content", "guid", @@ -972,6 +974,7 @@ class Database { "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", "arsse_enclosures.url as media_url", "arsse_enclosures.type as media_type", + "(select note from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note" ]; $q = $this->articleQuery($user, $context, $columns); $q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id"); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8390c50..78eddcc 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -29,6 +29,9 @@ Protocol difference so far: - setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result - The result of setArticleLabel counts only records which actually changed rather than all entries attempted - Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent + - Article hashes are SHA-256 rather than SHA-1. + - Articles have at most one attachment (enclosure), whereas TTRSS allows for several; there is also significantly less detail. These are limitations of picoFeed which should be addressed + - IDs for enclosures are ommitted as we don't give them IDs */ @@ -1169,4 +1172,68 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $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' => $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 getArticle operation should be amended accordingly + 'comments' => "", // FIXME: What is this? + 'author' => $article['author'], + 'updated' => Date::transform($article['edited_date'], "unix", "sql"), + 'feed_id' => $article['subscription'], + 'feed_title' => $article['subscription_title'], + 'attachments' => $article['media_url'] ? [[ + 'content_url' => $article['media_url'], + 'content_type' => $article['media_type'], + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => $article['id'], + ]] : [], // TODO: We need to support multiple enclosures + 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API + 'note' => strlen($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; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index c66c727..1900bc7 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -47,6 +47,48 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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", + ], + ]; protected function respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -1133,7 +1175,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['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' => 4], // no valid IDs + ['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); @@ -1182,4 +1224,92 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($out[$a], $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($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' => [ + [ + '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->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($this->respGood([$exp[1]]), $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index ab67768..90c36ea 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -46,21 +46,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' => [ @@ -69,22 +70,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' => [ @@ -198,9 +200,9 @@ trait SeriesArticle { [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',''], + [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'], @@ -243,6 +245,7 @@ trait SeriesArticle { 'id' => 101, 'url' => 'http://example.com/1', 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", 'author' => '', 'content' => '

Article content 1

', 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', @@ -256,11 +259,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', @@ -274,11 +279,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', @@ -292,11 +299,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', @@ -310,11 +319,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', @@ -328,6 +339,7 @@ trait SeriesArticle { 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', 'media_url' => "http://example.com/audio", 'media_type' => "audio/ogg", + 'note' => "", ], ]; From 6c8598d8973879879482eecfc7047790aad41a3b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 16 Nov 2017 15:56:14 -0500 Subject: [PATCH 47/66] Implement contexts for non-recursive folders, and any/no label Adjusted TTRSS handler accordingly --- lib/Database.php | 11 +++++++- lib/Misc/Context.php | 10 +++++++ lib/REST/TinyTinyRSS/API.php | 33 ++++------------------ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 26 ++++------------- tests/lib/Database/SeriesArticle.php | 14 ++++++--- 5 files changed, 40 insertions(+), 54 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 82a09e2..963c53b 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -845,6 +845,11 @@ class Database { $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"); + } 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", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); @@ -889,7 +894,11 @@ class Database { $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0"); } // filter based on label by ID or name - if ($context->label() || $context->labelName()) { + 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 { diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index a4fdd8c..82195df 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -10,6 +10,7 @@ class Context { public $limit = 0; public $offset = 0; public $folder; + public $folderShallow; public $subscription; public $oldestEdition; public $latestEdition; @@ -23,6 +24,7 @@ class Context { public $articles; public $label; public $labelName; + public $labelled = null; protected $props = []; @@ -64,6 +66,10 @@ class Context { return $this->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); } @@ -123,4 +129,8 @@ class Context { 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); + } } diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 78eddcc..2260bd8 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1039,35 +1039,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // not valid return $out; case self::CAT_UNCATEGORIZED: - // this is a special case - try { - $tr = Arsse::$db->begin(); - // filter the subscription list to return only uncategorized, and get their IDs - $list = array_column(Arsse::$db->subscriptionList(Arsse::$user->id, null, false)->getAll(), "id"); - // perform marking for each applicable subscription - foreach ($list as $id) { - Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->subscription($id)); - } - $tr->commit(); - } catch (ExceptionInput $e) { // @codeCoverageIgnore - // ignore errors; none should occur - } - return $out; + // this requires a shallow context since in TTRSS folder zero/null is apart from the tree rather than at the root + $c->folderShallow(0); + break; case self::CAT_LABELS: - // this is also a special case - try { - $tr = Arsse::$db->begin(); - // list all non-empty labels - $list = array_column(Arsse::$db->labelList(Arsse::$user->id, false)->getAll(), "id"); - // perform marking for each label - foreach ($list as $id) { - Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id)); - } - $tr->commit(); - } catch (ExceptionInput $e) { // @codeCoverageIgnore - // ignore errors; none should occur - } - return $out; + $c->labelled(true); + break; default: // any actual category $c->folder($id); diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 1900bc7..c7e2b98 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -941,13 +941,10 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112], ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true], - ]; - $in3 = [ - // complex context ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true], ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], ]; - $in4 = [ + $in3 = [ // this one has a tricky time-based context ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], ]; @@ -967,25 +964,12 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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)); - // reset the database mock - $this->setUp(); - Phake::when(Arsse::$db)->articleMark->thenReturn(42); - Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->filterSubs(null))); - Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); - // verify the complex contexts - for ($a = 0; $a < sizeof($in3); $a++) { - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in3[$a]))), "Test $a failed"); - } - Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(6)); - Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(3)); - Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1)); - Phake::verify(Arsse::$db, Phake::times(3))->articleMark; + 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($in4); $a++) { - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in4[$a]))), "Test $a failed"); + for ($a = 0; $a < sizeof($in3); $a++) { + $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in3[$a]))), "Test $a failed"); } Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 90c36ea..65c1569 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -237,6 +237,8 @@ trait SeriesArticle { [2,20,5,1], [1, 5,3,0], [2, 5,3,1], + [4, 7,4,0], + [4, 8,4,1], ], ], ]; @@ -362,11 +364,12 @@ trait SeriesArticle { $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)); @@ -405,6 +408,9 @@ trait SeriesArticle { // label by name $this->compareIds([1,19], (new Context)->labelName("Interesting")); $this->compareIds([1,5,20], (new Context)->labelName("Fascinating")); + // 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)); } public function testListArticlesOfAMissingFolder() { From c88b5c4f182c180bbd8e7224c56f5606a77d3702 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 17 Nov 2017 17:52:00 -0500 Subject: [PATCH 48/66] Make distinction between modified and marked for articles Also added various sets of fields to include when listing articles. --- lib/AbstractException.php | 6 +- lib/Database.php | 68 +++++++++++++------ lib/Misc/Context.php | 12 ++++ lib/REST/NextCloudNews/V1_2.php | 2 +- locale/en.php | 1 + tests/Misc/TestContext.php | 6 +- tests/REST/NextCloudNews/TestNCNV1_2.php | 2 +- tests/lib/Database/SeriesArticle.php | 84 ++++++++++++++++++------ 8 files changed, 135 insertions(+), 46 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index a79cd62..5c7ec40 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -7,7 +7,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse; abstract class AbstractException extends \Exception { - const CODES = [ "Exception.uncoded" => -1, + const CODES = [ + "Exception.uncoded" => -1, "Exception.unknown" => 10000, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, @@ -39,7 +40,8 @@ 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/Exception.constantUnknown" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Database.php b/lib/Database.php index 366b880..88d8679 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -16,6 +16,11 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { const SCHEMA_VERSION = 2; const LIMIT_ARTICLES = 50; + // articleList verbosity levels + const AL_MINIMAL = 0; // only that metadata which is required for context matching + const AL_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text + const AL_TYPICAL = 2; // conservative, with the addition of content + const AL_FULL = 3; // all possible fields /** @var Db\Driver */ public $db; @@ -824,10 +829,12 @@ class Database { $extraColumns arsse_articles.id as id, arsse_articles.feed as feed, + arsse_articles.modified as modified_date, 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, + 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, @@ -917,13 +924,19 @@ class Database { 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); @@ -959,7 +972,7 @@ class Database { } } - public function articleList(string $user, Context $context = null): Db\Result { + public function articleList(string $user, Context $context = null, int $fields = self::AL_FULL): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } @@ -969,26 +982,41 @@ class Database { $out = []; $tr = $this->begin(); foreach ($contexts as $context) { - $out[] = $this->articleList($user, $context); + $out[] = $this->articleList($user, $context, $fields); } $tr->commit(); return new Db\ResultAggregate(...$out); } else { - $columns = [ - // (id, subscription, feed, modified, unread, starred, edition): always included - "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", - "content", - "guid", - "published as published_date", - "edited as edited_date", - "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", - "arsse_enclosures.url as media_url", - "arsse_enclosures.type as media_type", - "(select note from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note" - ]; + $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::AL_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::AL_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::AL_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::AL_MINIMAL: // base metadata (always included: required for context matching) + // id, subscription, feed, modified_date, marked_date, unread, starred, edition + break; + default: + throw new Db\Exception("constantUnknown", $fields); + } $q = $this->articleQuery($user, $context, $columns); $q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id"); // perform the query and return results diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 8ebf454..edc9fb4 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -22,6 +22,8 @@ class Context { public $starred = null; public $modifiedSince; public $notModifiedSince; + public $markedSince; + public $notMarkedSince; public $edition; public $article; public $editions; @@ -104,6 +106,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); } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 2a50b99..1261fb5 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -506,7 +506,7 @@ 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 { diff --git a/locale/en.php b/locale/en.php index 286a2ff..f2f2148 100644 --- a/locale/en.php +++ b/locale/en.php @@ -142,6 +142,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointInvalid' => 'Tried to {action} invalid savepoint {index}', 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', + 'Exception.JKingWeb/Arsse/Db/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index 598789c..6626bf5 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -28,6 +28,7 @@ class TestContext extends Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folderShallow' => 42, 'subscription' => 2112, 'article' => 255, 'edition' => 65535, @@ -37,12 +38,15 @@ class TestContext extends Test\AbstractTest { '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, ]; - $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/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index 9680fb8..df126ab 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -700,7 +700,7 @@ class TestNCNV1_2 extends Test\AbstractTest { 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)->markedSince($t)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)); } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 5bf36bd..7b3d6bd 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -201,8 +201,8 @@ trait SeriesArticle { ], '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',''], + [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'], @@ -229,20 +229,21 @@ trait SeriesArticle { ], 'arsse_label_members' => [ 'columns' => [ - 'label' => "int", - 'article' => "int", + 'label' => "int", + 'article' => "int", 'subscription' => "int", - 'assigned' => "bool", + 'assigned' => "bool", + 'modified' => "datetime", ], '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], - [4, 7,4,0], - [4, 8,4,1], + [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'], ], ], ]; @@ -348,6 +349,26 @@ trait SeriesArticle { 'note' => "", ], ]; + protected $fields = [ + Database::AL_MINIMAL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", + ], + Database::AL_CONSERVATIVE => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", + "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + ], + Database::AL_TYPICAL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", + "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + "content", "media_url", "media_type", + ], + Database::AL_FULL => [ + "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", + "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + "content", "media_url", "media_type", + "note", + ], + ]; public function setUpSeries() { $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"],]; @@ -389,13 +410,18 @@ 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 (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)); @@ -406,15 +432,21 @@ 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)); - // label by ID + // get articles by label ID $this->compareIds([1,19], (new Context)->label(1)); $this->compareIds([1,5,20], (new Context)->label(2)); - // label by name + // get articles by label name $this->compareIds([1,19], (new Context)->labelName("Interesting")); $this->compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // any or no label + // 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])); } public function testListArticlesOfAMissingFolder() { @@ -430,6 +462,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", "Db", "Exception"); + Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX); } public function testListArticlesWithoutAuthority() { @@ -781,8 +823,8 @@ trait SeriesArticle { $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; @@ -792,8 +834,8 @@ 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,'']; From b595815eb93271c500c3f35f9e1b7169ab09ef9b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 17 Nov 2017 18:12:00 -0500 Subject: [PATCH 49/66] Change NCNv1 handler to use new "typical" articleList fieldset --- lib/Database.php | 18 ++++++------- lib/REST/NextCloudNews/V1_2.php | 3 ++- tests/REST/NextCloudNews/TestNCNV1_2.php | 32 ++++++++++++------------ tests/lib/Database/SeriesArticle.php | 8 +++--- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 88d8679..48ea6b8 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -17,10 +17,10 @@ class Database { const SCHEMA_VERSION = 2; const LIMIT_ARTICLES = 50; // articleList verbosity levels - const AL_MINIMAL = 0; // only that metadata which is required for context matching - const AL_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text - const AL_TYPICAL = 2; // conservative, with the addition of content - const AL_FULL = 3; // all possible fields + 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; @@ -972,7 +972,7 @@ class Database { } } - public function articleList(string $user, Context $context = null, int $fields = self::AL_FULL): Db\Result { + 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]); } @@ -990,17 +990,17 @@ class Database { $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::AL_FULL: // everything + 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::AL_TYPICAL: // conservative, plus content + 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::AL_CONSERVATIVE: // base metadata, plus anything that is not likely to be large text + 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", @@ -1011,7 +1011,7 @@ class Database { "edited as edited_date", "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", ]); - case self::AL_MINIMAL: // base metadata (always included: required for context matching) + case self::LIST_MINIMAL: // base metadata (always included: required for context matching) // id, subscription, feed, modified_date, marked_date, unread, starred, edition break; default: diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 1261fb5..0f02a34 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; @@ -510,7 +511,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // 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); diff --git a/tests/REST/NextCloudNews/TestNCNV1_2.php b/tests/REST/NextCloudNews/TestNCNV1_2.php index df126ab..c9a55f7 100644 --- a/tests/REST/NextCloudNews/TestNCNV1_2.php +++ b/tests/REST/NextCloudNews/TestNCNV1_2.php @@ -666,11 +666,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 @@ -691,17 +691,17 @@ 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)->markedSince($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() { diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 7b3d6bd..ec24d72 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -350,19 +350,19 @@ trait SeriesArticle { ], ]; protected $fields = [ - Database::AL_MINIMAL => [ + Database::LIST_MINIMAL => [ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", ], - Database::AL_CONSERVATIVE => [ + Database::LIST_CONSERVATIVE => [ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", ], - Database::AL_TYPICAL => [ + Database::LIST_TYPICAL => [ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", "content", "media_url", "media_type", ], - Database::AL_FULL => [ + Database::LIST_FULL => [ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", "content", "media_url", "media_type", From b0da9a1d06fb87cde9e89277678f65b1ccaff0ea Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 17 Nov 2017 19:08:35 -0500 Subject: [PATCH 50/66] Added annotation context to satisfy the TTRSS "has_note" view mode --- lib/Database.php | 4 ++++ lib/Misc/Context.php | 5 +++++ tests/Misc/TestContext.php | 1 + tests/lib/Database/SeriesArticle.php | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 48ea6b8..d67f386 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -944,6 +944,10 @@ class Database { if ($context->starred()) { $q->setWhere("starred is ?", "bool", $context->starred); } + // 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; } diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index edc9fb4..b36a032 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -31,6 +31,7 @@ class Context { public $label; public $labelName; public $labelled = null; + public $annotated = null; protected $props = []; @@ -149,4 +150,8 @@ class Context { 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/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index 6626bf5..13f11b7 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -45,6 +45,7 @@ class TestContext extends Test\AbstractTest { 'label' => 2112, 'labelName' => "Rush", 'labelled' => true, + 'annotated' => true, ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index ec24d72..afab611 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -212,6 +212,7 @@ trait SeriesArticle { [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_labels' => [ @@ -447,6 +448,9 @@ trait SeriesArticle { // 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() { From 50185ab8f6cd72e5f7a9cec4cc230a19a72c3896 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 17 Nov 2017 22:53:54 -0500 Subject: [PATCH 51/66] Change code and type of consantUnknown exception --- lib/AbstractException.php | 2 +- lib/Database.php | 2 +- locale/en.php | 3 ++- tests/lib/Database/SeriesArticle.php | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 5c7ec40..0806a13 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -10,6 +10,7 @@ abstract class AbstractException extends \Exception { const CODES = [ "Exception.uncoded" => -1, "Exception.unknown" => 10000, + "Exception.constantUnknown" => 10001, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, @@ -41,7 +42,6 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, - "Db/Exception.constantUnknown" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Database.php b/lib/Database.php index d67f386..00cd3ca 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1019,7 +1019,7 @@ class Database { // id, subscription, feed, modified_date, marked_date, unread, starred, edition break; default: - throw new Db\Exception("constantUnknown", $fields); + throw new Exception("constantUnknown", $fields); } $q = $this->articleQuery($user, $context, $columns); $q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id"); diff --git a/locale/en.php b/locale/en.php index f2f2148..e284847 100644 --- a/locale/en.php +++ b/locale/en.php @@ -85,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} @@ -142,7 +144,6 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointInvalid' => 'Tried to {action} invalid savepoint {index}', 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', - 'Exception.JKingWeb/Arsse/Db/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index afab611..9fcbbbf 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -474,7 +474,7 @@ trait SeriesArticle { $this->assertEquals($columns, $test, "Fields do not match expectation for verbosity $constant"); } // check that an unknown fieldset produces an exception - $this->assertException("constantUnknown", "Db", "Exception"); + $this->assertException("constantUnknown"); Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX); } From 2037efce612c0bbb69661b712339612e5df29e79 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 18 Nov 2017 16:06:49 -0500 Subject: [PATCH 52/66] Added oldestArticle and latestArticle context options --- lib/Database.php | 8 +++++++- lib/Misc/Context.php | 10 ++++++++++ tests/Misc/TestContext.php | 2 ++ tests/lib/Database/SeriesArticle.php | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index 00cd3ca..1ed8793 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -917,7 +917,13 @@ class Database { } $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 edition offset + // 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); + } if ($context->oldestEdition()) { $q->setWhere("edition >= ?", "int", $context->oldestEdition); } diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index b36a032..4ae64e1 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -16,6 +16,8 @@ class Context { public $folder; public $folderShallow; public $subscription; + public $oldestArticle; + public $latestArticle; public $oldestEdition; public $latestEdition; public $unread = null; @@ -81,6 +83,14 @@ class Context { 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); } diff --git a/tests/Misc/TestContext.php b/tests/Misc/TestContext.php index 13f11b7..4f9b330 100644 --- a/tests/Misc/TestContext.php +++ b/tests/Misc/TestContext.php @@ -32,6 +32,8 @@ class TestContext extends Test\AbstractTest { 'subscription' => 2112, 'article' => 255, 'edition' => 65535, + 'latestArticle' => 47, + 'oldestArticle' => 1337, 'latestEdition' => 47, 'oldestEdition' => 1337, 'unread' => true, diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 9fcbbbf..c474a67 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -411,6 +411,9 @@ 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 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")); From 5d4ea6edc0c6ec60063347772bf01b7461e44cf6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 19 Nov 2017 15:49:41 -0500 Subject: [PATCH 53/66] Add ResultEmpty class This allows for the creation of synthetic empty result sets --- lib/Db/AbstractResult.php | 14 +++++++++----- lib/Db/ResultEmpty.php | 25 ++++++++++++++++++++++++ tests/Db/TestResultEmpty.php | 37 ++++++++++++++++++++++++++++++++++++ tests/phpunit.xml | 1 + 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 lib/Db/ResultEmpty.php create mode 100644 tests/Db/TestResultEmpty.php diff --git a/lib/Db/AbstractResult.php b/lib/Db/AbstractResult.php index c1a60c6..fe4df26 100644 --- a/lib/Db/AbstractResult.php +++ b/lib/Db/AbstractResult.php @@ -17,15 +17,19 @@ abstract class AbstractResult implements Result { $out = array_shift($this->cur); $this->next(); return $out; + } else { + return null; } - $this->next(); - return null; } public function getRow() { - $out = ($this->valid() ? $this->cur : null); - $this->next(); - return $out; + if ($this->valid()) { + $out = $this->cur; + $this->next(); + return $out; + } else { + return null; + } } public function getAll(): array { diff --git a/lib/Db/ResultEmpty.php b/lib/Db/ResultEmpty.php new file mode 100644 index 0000000..f11d75e --- /dev/null +++ b/lib/Db/ResultEmpty.php @@ -0,0 +1,25 @@ + */ +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/phpunit.xml b/tests/phpunit.xml index 9d65e7b..3feab1b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -56,6 +56,7 @@ Db/TestTransaction.php Db/TestResultAggregate.php + Db/TestResultEmpty.php Db/SQLite3/TestDbResultSQLite3.php Db/SQLite3/TestDbStatementSQLite3.php Db/SQLite3/TestDbDriverCreationSQLite3.php From 5c140aedc48811cf97180f8e60fa7ae0af621273 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 20 Nov 2017 00:09:20 -0500 Subject: [PATCH 54/66] Implement TTRSS operation getCompactHeadlines; fixes #95 This commit also implements the back-end for the standard getHeadlines operation and handles all special feeds and categories; fixes #119 --- lib/REST/TinyTinyRSS/API.php | 209 +++++++++++++++++---- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 85 +++++++++ 2 files changed, 258 insertions(+), 36 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 04bc819..cbc5bf8 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Date; @@ -16,6 +17,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Db\ExceptionInput; +use JKingWeb\Arsse\Db\ResultEmpty; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; @@ -32,10 +34,13 @@ Protocol difference so far: - The "Published" virtual feed is non-functional (this will not be implemented in the near term) - setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result - The result of setArticleLabel counts only records which actually changed rather than all entries attempted + - Using both limit/skip and unread_only in getFeeds produces reliable results, unlike in TT-RSS - Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent - Article hashes are SHA-256 rather than SHA-1. - Articles have at most one attachment (enclosure), whereas TTRSS allows for several; there is also significantly less detail. These are limitations of picoFeed which should be addressed - IDs for enclosures are ommitted as we don't give them IDs + - Searching in getHeadlines is not yet implemented + - Category -3 (all non-special feeds) is handled correctly in getHeadlines; TT-RSS returns results for feed -3 (Fresh) */ @@ -59,35 +64,34 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const CAT_ALL = -4; // valid input const VALID_INPUT = [ - 'op' => ValueInfo::T_STRING, - 'sid' => ValueInfo::T_STRING, - 'seq' => ValueInfo::T_INT, - 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, - 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, - 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, - 'parent_id' => ValueInfo::T_INT, - 'category_id' => ValueInfo::T_INT, - 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, - 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, - 'feed_id' => ValueInfo::T_INT, - 'article_id' => ValueInfo::T_MIXED, // single integer or comma-separated list in getArticle - 'label_id' => ValueInfo::T_INT, - 'article_ids' => ValueInfo::T_STRING, - 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'cat_id' => ValueInfo::T_INT, - 'limit' => ValueInfo::T_INT, - 'offset' => ValueInfo::T_INT, - 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'skip' => ValueInfo::T_INT, - 'filter' => ValueInfo::T_STRING, - 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'op' => 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, - 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, 'since_id' => ValueInfo::T_INT, 'order_by' => ValueInfo::T_STRING, 'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP, @@ -95,12 +99,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP, 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, 'search' => ValueInfo::T_STRING, - 'search_mode' => ValueInfo::T_STRING, - 'match_on' => ValueInfo::T_STRING, - 'mode' => ValueInfo::T_INT, - 'field' => ValueInfo::T_INT, - 'data' => ValueInfo::T_STRING, - 'pref_name' => ValueInfo::T_STRING, + '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 + 'pref_name' => ValueInfo::T_STRING, // preference identifier in `getPref` ]; // generic error construct const FATAL_ERR = [ @@ -1033,7 +1035,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $id = $data['feed_id'] ?? self::FEED_ARCHIVED; $cat = $data['is_cat'] ?? false; $out = ['status' => "OK"]; - // first prepare the context; unsupported contexts simply return early, whereas some valid contexts are special cases + // first prepare the context; unsupported contexts simply return early $c = new Context; if ($cat) { // categories switch ($id) { @@ -1043,7 +1045,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // not valid return $out; case self::CAT_UNCATEGORIZED: - // this requires a shallow context since in TTRSS folder zero/null is apart from the tree rather than at the root + // 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: @@ -1217,4 +1219,139 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + public function opGetCompactHeadlines(array $data): array { + // getCompactHeadlines supports fewer features than getHeadlines + $data['is_cat'] = false; + $data['include_nested'] = false; + $data['search'] = null; + $data['order_by'] = null; + $out = []; + foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { + $out[] = ['id' => $row['id']]; + } + 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 + // 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 + try { + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + } catch (ExceptionInput $e) { + // if a category/feed does not exist + return new ResultEmpty; + } + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 9d4f30e..7bbda17 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -1300,4 +1300,89 @@ class TestTinyTinyAPI extends Test\AbstractTest { Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([])); $this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); } + + public function testGetCompactHeadlines() { + $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); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(2112), Database::LIST_MINIMAL)->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), new Context, Database::LIST_MINIMAL)->thenReturn(new Result($this->articles)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 2]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 3]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 4]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 5]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 6]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 7]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 8]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 9]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->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->assertEquals($out1[$a], $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]])); + $this->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed"); + } + } } From e729cedecacc5bce4bc3336ab3fe9c4d852dfa23 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 20 Nov 2017 09:49:47 -0500 Subject: [PATCH 55/66] Make getCompactHeadlines use a whitelist instead --- lib/REST/TinyTinyRSS/API.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index cbc5bf8..5c76626 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1222,10 +1222,15 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetCompactHeadlines(array $data): array { // getCompactHeadlines supports fewer features than getHeadlines - $data['is_cat'] = false; - $data['include_nested'] = false; - $data['search'] = null; - $data['order_by'] = null; + $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 = []; foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { $out[] = ['id' => $row['id']]; From faf00d63ba41f2c79b2ed445fe456c88cf0e230d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 21 Nov 2017 09:22:58 -0500 Subject: [PATCH 56/66] Add Database::articleCategoriesGet() This method retrieves author-supplied categories for articles, used in TTRSS --- lib/Database.php | 14 +++++++++++ tests/lib/Database/SeriesArticle.php | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 1ed8793..07fbc39 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1162,6 +1162,20 @@ class Database { } } + 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 { $query = $this->db->prepare( "WITH target_feed(id,subs) as (". diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index c474a67..40f748b 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -215,6 +215,18 @@ trait SeriesArticle { [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", @@ -907,9 +919,34 @@ trait SeriesArticle { $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); + } } From c669273792afcc6f6691f749a3475d857c6d96e3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 22 Nov 2017 20:18:16 -0500 Subject: [PATCH 57/66] Implement TTRSS operation getHeadlines; fixe #82 --- lib/REST/TinyTinyRSS/API.php | 144 ++++++-- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 379 ++++++++++++++++++++- tests/phpunit.xml | 12 +- 3 files changed, 495 insertions(+), 40 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 5c76626..d83ac3e 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -41,14 +41,19 @@ Protocol difference so far: - IDs for enclosures are ommitted as we don't give them IDs - Searching in getHeadlines is not yet implemented - Category -3 (all non-special feeds) is handled correctly in getHeadlines; TT-RSS returns results for feed -3 (Fresh) + - Sorting of headlines does not match TT-RSS: special feeds are not sorted specially like they should be + - The 'sanitize', 'force_update', and 'has_sandbox' parameters of getHeadlines are ignored + - The 'always_display_attachments' key of articles in getHeadlines is omitted, as the user cannot express a preference */ class API extends \JKingWeb\Arsse\REST\AbstractHandler { - const LEVEL = 14; - const VERSION = "17.4"; - const LABEL_OFFSET = 1024; + const LEVEL = 14; // emulated API level + const VERSION = "17.4"; // emulated TT-RSS version + const LABEL_OFFSET = 1024; // offset below zero at which labels begin, counting down + const LIMIT_ARTICLES = 200; // maximum number of articles returned by getHeadlines + const LIMIT_EXCERPT = 100; // maximum length of excerpts in getHeadlines, counted in grapheme units // special feeds const FEED_ARCHIVED = 0; const FEED_STARRED = -1; @@ -91,18 +96,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { '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, - 'since_id' => ValueInfo::T_INT, - 'order_by' => ValueInfo::T_STRING, - 'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'force_update' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, - 'search' => ValueInfo::T_STRING, + '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 - 'pref_name' => ValueInfo::T_STRING, // preference identifier in `getPref` ]; // generic error construct const FATAL_ERR = [ @@ -1232,8 +1233,101 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $data = $this->normalizeInput($data, self::VALID_INPUT, "unix"); // fetch the list of IDs $out = []; - foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { - $out[] = ['id' => $row['id']]; + 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'] ? 200 : $data['limit'], 200), 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' => $article['subscription'], + 'feed_title' => $article['subscription_title'], + 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API + 'note' => strlen($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' => "", + ]; + 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'] ? [[ + 'content_url' => $article['media_url'], + 'content_type' => $article['media_type'], + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => $article['id'], + ]] : []; // 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; } @@ -1340,6 +1434,21 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 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']); @@ -1352,11 +1461,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c->oldestArticle($data['since_id'] + 1); } // return results - try { - return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); - } catch (ExceptionInput $e) { - // if a category/feed does not exist - return new ResultEmpty; - } + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 7bbda17..8a176dc 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -93,6 +93,29 @@ class TestTinyTinyAPI extends Test\AbstractTest { '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 respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -1301,7 +1324,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); } - public function testGetCompactHeadlines() { + public function testRetrieveCompactHeadlines() { $in1 = [ // erroneous input ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"], @@ -1336,18 +1359,19 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(2112), Database::LIST_MINIMAL)->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), new Context, Database::LIST_MINIMAL)->thenReturn(new Result($this->articles)); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 2]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 3]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 4]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 5]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 6]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 7]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 8]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 9]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->oldestArticle(48), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 10]])); + $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([]), @@ -1379,10 +1403,333 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($out1[$a], $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]])); + 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->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + } + for ($a = 0; $a < sizeof($in2); $a++) { + $this->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($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->assertEquals($out3[$a], $this->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($in[0]))); + $this->assertEquals($this->outputHeadlines(1), $test); + // test 'show_content' + $test = $this->h->dispatch(new Request("POST", "", json_encode($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->h->dispatch(new Request("POST", "", json_encode($in[2]))); + $exp = [ + [ + '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->h->dispatch(new Request("POST", "", json_encode($in[3]))); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -4, 'is_cat' => false, 'first_id' => 1], + $exp->payload['content'], + ]; + $this->assertEquals($exp, $test); + // test 'include_header' with a category + $test = $this->h->dispatch(new Request("POST", "", json_encode($in[4]))); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -3, 'is_cat' => true, 'first_id' => 1], + $exp->payload['content'], + ]; + $this->assertEquals($exp, $test); + // test 'include_header' with an empty result + $test = $this->h->dispatch(new Request("POST", "", json_encode($in[5]))); + $exp = $this->respGood([ + ['id' => -1, 'is_cat' => true, 'first_id' => 0], + [], + ]); + $this->assertEquals($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->h->dispatch(new Request("POST", "", json_encode($in[6]))); + $exp = $this->respGood([ + ['id' => 2112, 'is_cat' => false, 'first_id' => 0], + [], + ]); + $this->assertEquals($exp, $test); + // test 'include_header' with ascending order + $test = $this->h->dispatch(new Request("POST", "", json_encode($in[7]))); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => -4, 'is_cat' => false, 'first_id' => 0], + $exp->payload['content'], + ]; + $this->assertEquals($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->h->dispatch(new Request("POST", "", json_encode($in[8]))); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => 42, 'is_cat' => false, 'first_id' => 1867], + $exp->payload['content'], + ]; + $this->assertEquals($exp, $test); + // test 'include_header' with skip and ascending order + $test = $this->h->dispatch(new Request("POST", "", json_encode($in[9]))); + $exp = $this->outputHeadlines(1); + $exp->payload['content'] = [ + ['id' => 42, 'is_cat' => false, 'first_id' => 0], + $exp->payload['content'], + ]; + $this->assertEquals($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->h->dispatch(new Request("POST", "", json_encode($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' => "", + ], + [ + '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' => "", + ], + ]); + } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 3feab1b..0e06627 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -76,10 +76,14 @@ Db/SQLite3/Database/TestDatabaseCleanupSQLite3.php
- REST/NextCloudNews/TestNCNVersionDiscovery.php - REST/NextCloudNews/TestNCNV1_2.php - REST/TinyTinyRSS/TestTinyTinyAPI.php - REST/TinyTinyRSS/TestTinyTinyIcon.php + + REST/NextCloudNews/TestNCNVersionDiscovery.php + REST/NextCloudNews/TestNCNV1_2.php + + + REST/TinyTinyRSS/TestTinyTinyAPI.php + REST/TinyTinyRSS/TestTinyTinyIcon.php + Service/TestService.php From a61aa0a22c0a00a4d4cf2566c55d527e51552546 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 23 Nov 2017 14:05:26 -0500 Subject: [PATCH 58/66] Simplify TTRSS test request boilerplate --- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 272 +++++++++++---------- 1 file changed, 138 insertions(+), 134 deletions(-) diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 8a176dc..bb16cc0 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -117,6 +117,10 @@ class TestTinyTinyAPI extends Test\AbstractTest { 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, @@ -181,13 +185,13 @@ LONG_STRING; 'password' => "secret", ]; $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); // test a failed log-in $data['password'] = "superman"; $exp = $this->respErr("LOGIN_ERROR"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); // logging in should never try to resume a session Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); } @@ -199,7 +203,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => "OK"]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); } @@ -209,10 +213,10 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => true]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); $data['sid'] = "SolarFederation"; $exp = $this->respErr("NOT_LOGGED_IN"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); } public function testRetrieveServerVersion() { @@ -224,7 +228,7 @@ LONG_STRING; 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); } public function testRetrieveProtocolLevel() { @@ -233,7 +237,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($data)))); + $this->assertEquals($exp, $this->req($data)); } public function testAddACategory() { @@ -267,24 +271,24 @@ LONG_STRING; Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders $exp = $this->respGood(2); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); $exp = $this->respGood(3); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // attempt to add the two folders again $exp = $this->respGood(2); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); $exp = $this->respGood(3); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->req($in[2])); // add some invalid folders $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); } public function testRemoveACategory() { @@ -297,16 +301,16 @@ LONG_STRING; Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // delete a folder which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // delete an invalid folder (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->req($in[2])); Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); } @@ -344,21 +348,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); // succefully move a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // move a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // move a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[6])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); + $this->assertEquals($exp, $this->req($in[7])); + $this->assertEquals($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -384,21 +388,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // rename a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // rename a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); + $this->assertEquals($exp, $this->req($in[6])); + $this->assertEquals($exp, $this->req($in[7])); + $this->assertEquals($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -468,11 +472,11 @@ LONG_STRING; 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Failed test $a"); + $this->assertEquals($exp, $this->req($in[$a]), "Failed test $a"); } $exp = $this->respErr("INCORRECT_USAGE"); for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) { - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Failed test $a"); + $this->assertEquals($exp, $this->req($in[$a]), "Failed test $a"); } Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]); } @@ -489,13 +493,13 @@ LONG_STRING; 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // try deleting it again (this should noisily fail, as should everything else) $exp = $this->respErr("FEED_NOT_FOUND"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->req($in[0])); + $this->assertEquals($exp, $this->req($in[1])); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); } @@ -523,21 +527,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); // succefully move a subscription $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // move a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // move a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); + $this->assertEquals($exp, $this->req($in[6])); + $this->assertEquals($exp, $this->req($in[7])); + $this->assertEquals($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -563,21 +567,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a subscription $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // rename a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // rename a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); + $this->assertEquals($exp, $this->req($in[6])); + $this->assertEquals($exp, $this->req($in[7])); + $this->assertEquals($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(3))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -589,7 +593,7 @@ LONG_STRING; ['id' => 3, 'unread' => 47], ])); $exp = $this->respGood(['unread' => 2112 + 42 + 47]); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in)))); + $this->assertEquals($exp, $this->req($in)); } public function testRetrieveTheServerConfiguration() { @@ -603,8 +607,8 @@ LONG_STRING; ['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->assertEquals($this->respGood($exp[0]), $this->h->dispatch(new Request("POST", "", json_encode($in)))); - $this->assertEquals($this->respGood($exp[1]), $this->h->dispatch(new Request("POST", "", json_encode($in)))); + $this->assertEquals($this->respGood($exp[0]), $this->req($in)); + $this->assertEquals($this->respGood($exp[1]), $this->req($in)); } public function testUpdateAFeed() { @@ -618,13 +622,13 @@ LONG_STRING; 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); Phake::verify(Arsse::$db)->feedUpdate(11); $exp = $this->respErr("FEED_NOT_FOUND"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); } public function testAddALabel() { @@ -655,21 +659,21 @@ LONG_STRING; 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // attempt to add the two labels again $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[4])); } public function testRemoveALabel() { @@ -684,18 +688,18 @@ LONG_STRING; Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a label $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // delete a label which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // delete some invalid labels (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($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); } @@ -728,21 +732,21 @@ LONG_STRING; Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); // succefully rename a label $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->req($in[0])); // rename a label which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->req($in[1])); // rename a label causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + $this->assertEquals($exp, $this->req($in[3])); + $this->assertEquals($exp, $this->req($in[4])); + $this->assertEquals($exp, $this->req($in[5])); + $this->assertEquals($exp, $this->req($in[6])); + $this->assertEquals($exp, $this->req($in[7])); + $this->assertEquals($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -814,7 +818,7 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertEquals($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + $this->assertEquals($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); } } @@ -847,7 +851,7 @@ LONG_STRING; ['id' => 1, 'kind' => "cat", 'counter' => 7], ['id' => -2, 'kind' => "cat", 'counter' => 6], ]; - $this->assertResponse($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in)))); + $this->assertResponse($this->respGood($exp), $this->req($in)); } public function testRetrieveTheLabelList() { @@ -891,7 +895,7 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertResponse($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); } } @@ -917,20 +921,20 @@ LONG_STRING; 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->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $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->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $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' => 89]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertResponse($exp, $this->req($in[2])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $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() { @@ -945,9 +949,9 @@ LONG_STRING; 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'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:4','bare_id'=>4,'auxcounter'=>0,'name'=>'Photography','items'=>[],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(0 feeds)',],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; - $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($this->respGood($exp), $this->req($in[0])); $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; - $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($this->respGood($exp), $this->req($in[1])); } public function testMarkFeedsAsRead() { @@ -979,12 +983,12 @@ LONG_STRING; $exp = $this->respGood(['status' => "OK"]); // verify the above are in fact no-ops for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + $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->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed"); + $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)); @@ -996,7 +1000,7 @@ LONG_STRING; // verify the time-based mock $t = Date::sub("PT24H"); for ($a = 0; $a < sizeof($in3); $a++) { - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", json_encode($in3[$a]))), "Test $a failed"); + $this->assertResponse($exp, $this->req($in3[$a]), "Test $a failed"); } Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); } @@ -1131,10 +1135,10 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($this->respGood($exp[$a]), $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + $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->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed"); + $this->assertResponse($this->respGood([]), $this->req($in2[$a]), "Test $a failed"); } } @@ -1232,7 +1236,7 @@ LONG_STRING; $this->respErr("INCORRECT_USAGE"), ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertEquals($out[$a], $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); + $this->assertEquals($out[$a], $this->req($in[$a]), "Test $a failed"); } } @@ -1256,10 +1260,10 @@ LONG_STRING; 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->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->req($in[0])); + $this->assertEquals($exp, $this->req($in[1])); + $this->assertEquals($exp, $this->req($in[2])); + $this->assertEquals($exp, $this->req($in[3])); $exp = [ [ 'id' => 101, @@ -1315,13 +1319,13 @@ LONG_STRING; 'content' => '

Article content 2

', ], ]; - $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); - $this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($this->respGood([$exp[1]]), $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($this->respGood($exp), $this->req($in[4])); + $this->assertEquals($this->respGood([$exp[0]]), $this->req($in[5])); + $this->assertEquals($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->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($this->respGood([$exp[0]]), $this->req($in[5])); } public function testRetrieveCompactHeadlines() { @@ -1400,13 +1404,13 @@ LONG_STRING; $this->respGood([['id' => 1003]]), ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertEquals($out1[$a], $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + $this->assertEquals($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->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed"); + $this->assertEquals($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1508,16 +1512,16 @@ LONG_STRING; $this->outputHeadlines(1003), ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($this->respGood([]), $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed"); + $this->assertResponse($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - $this->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed"); + $this->assertEquals($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->assertEquals($out3[$a], $this->h->dispatch(new Request("POST", "", json_encode($in3[$a]))), "Test $a failed"); + $this->assertEquals($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1546,17 +1550,17 @@ LONG_STRING; 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->h->dispatch(new Request("POST", "", json_encode($in[0]))); + $test = $this->req($in[0]); $this->assertEquals($this->outputHeadlines(1), $test); // test 'show_content' - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[1]))); + $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->h->dispatch(new Request("POST", "", json_encode($in[2]))); + $test = $this->req($in[2]); $exp = [ [ 'content_url' => "http://example.com/text", @@ -1573,7 +1577,7 @@ LONG_STRING; $this->assertSame([], $test->payload['content'][0]['attachments']); $this->assertSame($exp, $test->payload['content'][1]['attachments']); // test 'include_header' - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[3]))); + $test = $this->req($in[3]); $exp = $this->outputHeadlines(1); $exp->payload['content'] = [ ['id' => -4, 'is_cat' => false, 'first_id' => 1], @@ -1581,7 +1585,7 @@ LONG_STRING; ]; $this->assertEquals($exp, $test); // test 'include_header' with a category - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[4]))); + $test = $this->req($in[4]); $exp = $this->outputHeadlines(1); $exp->payload['content'] = [ ['id' => -3, 'is_cat' => true, 'first_id' => 1], @@ -1589,7 +1593,7 @@ LONG_STRING; ]; $this->assertEquals($exp, $test); // test 'include_header' with an empty result - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[5]))); + $test = $this->req($in[5]); $exp = $this->respGood([ ['id' => -1, 'is_cat' => true, 'first_id' => 0], [], @@ -1597,14 +1601,14 @@ LONG_STRING; $this->assertEquals($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->h->dispatch(new Request("POST", "", json_encode($in[6]))); + $test = $this->req($in[6]); $exp = $this->respGood([ ['id' => 2112, 'is_cat' => false, 'first_id' => 0], [], ]); $this->assertEquals($exp, $test); // test 'include_header' with ascending order - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[7]))); + $test = $this->req($in[7]); $exp = $this->outputHeadlines(1); $exp->payload['content'] = [ ['id' => -4, 'is_cat' => false, 'first_id' => 0], @@ -1613,7 +1617,7 @@ LONG_STRING; $this->assertEquals($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->h->dispatch(new Request("POST", "", json_encode($in[8]))); + $test = $this->req($in[8]); $exp = $this->outputHeadlines(1); $exp->payload['content'] = [ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], @@ -1621,7 +1625,7 @@ LONG_STRING; ]; $this->assertEquals($exp, $test); // test 'include_header' with skip and ascending order - $test = $this->h->dispatch(new Request("POST", "", json_encode($in[9]))); + $test = $this->req($in[9]); $exp = $this->outputHeadlines(1); $exp->payload['content'] = [ ['id' => 42, 'is_cat' => false, 'first_id' => 0], @@ -1631,7 +1635,7 @@ LONG_STRING; // 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->h->dispatch(new Request("POST", "", json_encode($in[10]))); + $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']); From b820a004d608fece06e2051969c1431199e3610d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 23 Nov 2017 18:07:56 -0500 Subject: [PATCH 59/66] Complete testing of TTRSS handler Also implemented OPTIONS handling for TTRSS; improves #107 --- lib/REST/TinyTinyRSS/API.php | 35 ++++++---------- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 49 ++++++++++++++++++++++ 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d83ac3e..354abfa 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -109,26 +109,25 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const FATAL_ERR = [ 'seq' => null, 'status' => 1, - 'content' => ['error' => "NOT_LOGGED_IN"], + 'content' => ['error' => "MALFORMED_INPUT"], ]; public function __construct() { } public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { - if ($req->method != "POST") { - // only POST requests are allowed - return new Response(405, self::FATAL_ERR, "application/json", ["Allow: POST"]); + 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 - if (!preg_match("<^application/json\b|^$>", $req->type)) { - return new Response(415, self::FATAL_ERR, "application/json", ['Accept: application/json']); - } + // 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)) { - // non-JSON input indicates an error - return new Response(400, self::FATAL_ERR); + return new Response(200, self::FATAL_ERR); } try { // normalize input @@ -144,16 +143,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } $method = "op".ucfirst($data['op']); if (!method_exists($this, $method)) { - // because method names are supposed to be case insensitive, we need to try a bit harder to match - $method = strtolower($method); - $map = get_class_methods($this); - $map = array_combine(array_map("strtolower", $map), $map); - if (!array_key_exists($method, $map)) { - // if the method really doesn't exist, throw an exception - throw new Exception("UNKNWON_METHOD", ['method' => $data['op']]); - } - // otherwise retrieve the correct camelCase and continue - $method = $map[$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'], @@ -171,7 +162,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } else { // absence of a request body indicates an error - return new Response(400, self::FATAL_ERR); + return new Response(200, self::FATAL_ERR); } } @@ -1245,7 +1236,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetHeadlines(array $data): array { // normalize input - $data['limit'] = max(min(!$data['limit'] ? 200 : $data['limit'], 200), 0); // at most 200; not specified/zero yields 200; negative values yield no limit + $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 = []; diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index bb16cc0..e5a4558 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -176,6 +176,20 @@ LONG_STRING; $this->clearData(); } + public function testHandleOptionsRequest() { + $exp = new Response(204, "", "", [ + "Allow: POST", + "Accept: application/json, text/json", + ]); + $this->assertEquals($exp, $this->h->dispatch(new Request("OPTIONS", ""))); + } + + public function testHandleInvalidData() { + $exp = $this->RESPERR("MALFORMED_INPUT"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", "This is not valid JSON data"))); + $this->assertEquals($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"); @@ -196,6 +210,17 @@ LONG_STRING; 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->assertEquals($exp, $this->req($data)); + } + public function testLogOut() { Phake::when(Arsse::$db)->sessionDestroy->thenReturn(true); $data = [ @@ -219,6 +244,30 @@ LONG_STRING; $this->assertEquals($exp, $this->req($data)); } + public function testHandleUnknownMethods() { + $exp = $this->respErr("UNKNOWN_METHOD", ['method' => "thisMethodDoesNotExist"]); + $data = [ + 'op' => "thisMethodDoesNotExist", + 'sid' => "PriestsOfSyrinx", + ]; + $this->assertEquals($exp, $this->req($data)); + } + + public function testHandleMixedCaseMethods() { + $data = [ + 'op' => "isLoggedIn", + 'sid' => "PriestsOfSyrinx", + ]; + $exp = $this->respGood(['status' => true]); + $this->assertEquals($exp, $this->req($data)); + $data['op'] = "isloggedin"; + $this->assertEquals($exp, $this->req($data)); + $data['op'] = "ISLOGGEDIN"; + $this->assertEquals($exp, $this->req($data)); + $data['op'] = "iSlOgGeDiN"; + $this->assertEquals($exp, $this->req($data)); + } + public function testRetrieveServerVersion() { $data = [ 'op' => "getVersion", From 05620602bfeaf53826f9e140721f2e13e22aa7de Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 23 Nov 2017 18:12:18 -0500 Subject: [PATCH 60/66] Tweak --- lib/REST/TinyTinyRSS/API.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 354abfa..c8c0a90 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -24,7 +24,7 @@ use JKingWeb\Arsse\REST\Response; /* Protocol difference so far: - - Handling of incorrect Content-Type and/or HTTP method is different + - Malformed JSON data returns a different error code than login failure, for clarity - TT-RSS accepts whitespace-only names for categories, labels, and feeds; we do not - TT-RSS allows two folders to share the same name under the same parent; we do not - TT-RSS requires the user to choose in the face of multiple found feeds during discovery; we use the first one (picoFeed limitation) From 999f2552032d4f37e2918dfa57caef7f92f28879 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 27 Nov 2017 13:05:08 -0500 Subject: [PATCH 61/66] Fixes for bugs uncovered during client testing --- dist/nginx.conf | 2 +- lib/REST/TinyTinyRSS/API.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/nginx.conf b/dist/nginx.conf index 94cff1c..7900567 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -45,6 +45,6 @@ server { # Tiny Tiny RSS special-feed icons location /tt-rss/images/ { root /usr/share/arsse/www; - try_files $uri; + try_files $uri =404; } } \ No newline at end of file diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index c8c0a90..4417a33 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1188,7 +1188,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'post_id' => $article['id'], ]] : [], // TODO: We need to support multiple enclosures 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API - 'note' => strlen($article['note']) ? $article['note'] : null, + 'note' => strlen((string) $article['note']) ? $article['note'] : null, 'lang' => "", // FIXME: picoFeed should be able to retrieve this information 'content' => $article['content'], ]; @@ -1262,7 +1262,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'feed_id' => $article['subscription'], 'feed_title' => $article['subscription_title'], 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API - 'note' => strlen($article['note']) ? $article['note'] : null, + '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, From a0bf7c16bdfc057a521d0244ca0d60f3ebfd4235 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 27 Nov 2017 14:11:35 -0500 Subject: [PATCH 62/66] Fix sorting Also avoid sorting when it's not needed by queries --- lib/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index 07fbc39..577d3b3 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -841,7 +841,6 @@ class Database { 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()) { @@ -1028,6 +1027,7 @@ class Database { throw new Exception("constantUnknown", $fields); } $q = $this->articleQuery($user, $context, $columns); + $q->setOrder("edited_date".($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()); From 91165cdd0d49124de6b0d21393584fc83688aff7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 27 Nov 2017 15:05:50 -0500 Subject: [PATCH 63/66] Fix breakage caused by last comit --- lib/Database.php | 6 +++++- tests/lib/Database/SeriesArticle.php | 14 +++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 577d3b3..e7f33cf 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1021,13 +1021,17 @@ class Database { "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", ]); case self::LIST_MINIMAL: // base metadata (always included: required for context matching) - // id, subscription, feed, modified_date, marked_date, unread, starred, edition + $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()); diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 40f748b..caf6537 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -364,20 +364,20 @@ trait SeriesArticle { ]; protected $fields = [ Database::LIST_MINIMAL => [ - "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", + "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", - "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + "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", - "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + "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", - "url", "title", "subscription_title", "author", "guid", "published_date", "edited_date", "fingerprint", + "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", ], From b4890eaced141a6d78e23b751be8d986a13c60be Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 29 Nov 2017 09:22:59 -0500 Subject: [PATCH 64/66] Make TTRSS tests consider data types; improves #125 --- lib/REST/TinyTinyRSS/API.php | 27 +- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 282 ++++++++++----------- 2 files changed, 153 insertions(+), 156 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 4417a33..406ef6d 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -269,25 +269,30 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $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, pruning categories with no unread, and building a final category list - $cats = []; - while ($categories) { - foreach ($categories as $c) { + // 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 - $categories[$catmap[$c['parent']]]['counter'] += $c['counter']; - $categories[$catmap[$c['parent']]]['children'] -= 1; - } - if ($c['counter']) { - // if the category's counter is non-zero, add the category to the output list - $cats[] = ['id' => $c['id'], 'kind' => "cat", 'counter' => $c['counter']]; + $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($categories[$catmap[$c['id']]]); + 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 diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index e5a4558..9842b8f 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -139,16 +139,8 @@ LONG_STRING; } protected function assertResponse(Response $exp, Response $act, string $text = null) { - if ($exp->payload['status']) { - // if the expectation is an error response, do a straight object comparison - $this->assertEquals($exp, $act, $text); - } else { - // otherwise just compare their content - foreach ($act->payload['content'] as $record) { - $this->assertContains($record, $exp->payload['content'], $text); - } - $this->assertCount(sizeof($exp->payload['content']), $act->payload['content'], $text); - } + $this->assertEquals($exp, $act, $text); + $this->assertSame($exp->payload, $act->payload, $text); } public function setUp() { @@ -181,13 +173,13 @@ LONG_STRING; "Allow: POST", "Accept: application/json, text/json", ]); - $this->assertEquals($exp, $this->h->dispatch(new Request("OPTIONS", ""))); + $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", ""))); } public function testHandleInvalidData() { - $exp = $this->RESPERR("MALFORMED_INPUT"); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", "This is not valid JSON data"))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", ""))); // lack of data is also an error + $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() { @@ -199,13 +191,13 @@ LONG_STRING; 'password' => "secret", ]; $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); // test a failed log-in $data['password'] = "superman"; $exp = $this->respErr("LOGIN_ERROR"); - $this->assertEquals($exp, $this->req($data)); + $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()); } @@ -218,7 +210,7 @@ LONG_STRING; 'password' => "secret", ]; $exp = new Response(500); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testLogOut() { @@ -228,7 +220,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => "OK"]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); } @@ -238,10 +230,10 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => true]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); $data['sid'] = "SolarFederation"; $exp = $this->respErr("NOT_LOGGED_IN"); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testHandleUnknownMethods() { @@ -250,7 +242,7 @@ LONG_STRING; 'op' => "thisMethodDoesNotExist", 'sid' => "PriestsOfSyrinx", ]; - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testHandleMixedCaseMethods() { @@ -259,13 +251,13 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => true]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); $data['op'] = "isloggedin"; - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); $data['op'] = "ISLOGGEDIN"; - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); $data['op'] = "iSlOgGeDiN"; - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testRetrieveServerVersion() { @@ -277,7 +269,7 @@ LONG_STRING; 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testRetrieveProtocolLevel() { @@ -286,7 +278,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertEquals($exp, $this->req($data)); + $this->assertResponse($exp, $this->req($data)); } public function testAddACategory() { @@ -320,24 +312,24 @@ LONG_STRING; Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders $exp = $this->respGood(2); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); $exp = $this->respGood(3); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // attempt to add the two folders again $exp = $this->respGood(2); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); $exp = $this->respGood(3); - $this->assertEquals($exp, $this->req($in[1])); + $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->assertEquals($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[2])); // add some invalid folders $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[5])); } public function testRemoveACategory() { @@ -350,16 +342,16 @@ LONG_STRING; Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // delete a folder which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // delete an invalid folder (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[2])); Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); } @@ -397,21 +389,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); // succefully move a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // move a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // move a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[6])); + $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->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); - $this->assertEquals($exp, $this->req($in[7])); - $this->assertEquals($exp, $this->req($in[8])); + $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()); } @@ -437,21 +429,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a folder $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // rename a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // rename a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); - $this->assertEquals($exp, $this->req($in[6])); - $this->assertEquals($exp, $this->req($in[7])); - $this->assertEquals($exp, $this->req($in[8])); + $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()); } @@ -521,11 +513,11 @@ LONG_STRING; 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->assertEquals($exp, $this->req($in[$a]), "Failed test $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->assertEquals($exp, $this->req($in[$a]), "Failed test $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]); } @@ -542,13 +534,13 @@ LONG_STRING; 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->assertEquals($exp, $this->req($in[0])); + $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->assertEquals($exp, $this->req($in[0])); - $this->assertEquals($exp, $this->req($in[1])); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); + $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()); } @@ -576,21 +568,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); // succefully move a subscription $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // move a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // move a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); + $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->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); - $this->assertEquals($exp, $this->req($in[6])); - $this->assertEquals($exp, $this->req($in[7])); - $this->assertEquals($exp, $this->req($in[8])); + $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()); } @@ -616,21 +608,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a subscription $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // rename a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // rename a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); - $this->assertEquals($exp, $this->req($in[6])); - $this->assertEquals($exp, $this->req($in[7])); - $this->assertEquals($exp, $this->req($in[8])); + $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()); } @@ -641,8 +633,8 @@ LONG_STRING; ['id' => 2, 'unread' => 42], ['id' => 3, 'unread' => 47], ])); - $exp = $this->respGood(['unread' => 2112 + 42 + 47]); - $this->assertEquals($exp, $this->req($in)); + $exp = $this->respGood(['unread' => (2112 + 42 + 47)]); + $this->assertResponse($exp, $this->req($in)); } public function testRetrieveTheServerConfiguration() { @@ -656,8 +648,8 @@ LONG_STRING; ['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->assertEquals($this->respGood($exp[0]), $this->req($in)); - $this->assertEquals($this->respGood($exp[1]), $this->req($in)); + $this->assertResponse($this->respGood($exp[0]), $this->req($in)); + $this->assertResponse($this->respGood($exp[1]), $this->req($in)); } public function testUpdateAFeed() { @@ -671,13 +663,13 @@ LONG_STRING; 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->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); Phake::verify(Arsse::$db)->feedUpdate(11); $exp = $this->respErr("FEED_NOT_FOUND"); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); } public function testAddALabel() { @@ -708,21 +700,21 @@ LONG_STRING; 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->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // attempt to add the two labels again $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertEquals($exp, $this->req($in[1])); + $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->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); + $this->assertResponse($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[3])); + $this->assertResponse($exp, $this->req($in[4])); } public function testRemoveALabel() { @@ -737,18 +729,18 @@ LONG_STRING; Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a label $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // delete a label which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // delete some invalid labels (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); + $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); } @@ -781,21 +773,21 @@ LONG_STRING; Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); // succefully rename a label $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[0])); + $this->assertResponse($exp, $this->req($in[0])); // rename a label which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[1])); + $this->assertResponse($exp, $this->req($in[1])); // rename a label causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertEquals($exp, $this->req($in[2])); + $this->assertResponse($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertEquals($exp, $this->req($in[3])); - $this->assertEquals($exp, $this->req($in[4])); - $this->assertEquals($exp, $this->req($in[5])); - $this->assertEquals($exp, $this->req($in[6])); - $this->assertEquals($exp, $this->req($in[7])); - $this->assertEquals($exp, $this->req($in[8])); + $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()); } @@ -867,7 +859,7 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertEquals($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); + $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); } } @@ -888,11 +880,11 @@ LONG_STRING; ['id' => -4, 'counter' => 35, 'auxcounter' => 0], ['id' => -1027, 'counter' => 6, 'auxcounter' => 100], ['id' => -1025, 'counter' => 0, 'auxcounter' => 2], - ['id' => 3, 'has_img' => 1, 'counter' => 2, 'updated' => "2016-05-23T06:40:02"], - ['id' => 4, 'has_img' => 1, 'counter' => 6, 'updated' => "2017-10-09T15:58:34"], - ['id' => 1, 'has_img' => 0, 'counter' => 5, 'updated' => "2017-09-15T22:54:16"], - ['id' => 5, 'has_img' => 0, 'counter' => 12, 'updated' => "2017-07-07T17:07:17"], - ['id' => 2, 'has_img' => 1, 'counter' => 10, 'updated' => "2011-11-11T11:11:11"], + ['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], @@ -977,7 +969,7 @@ LONG_STRING; $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' => 89]); + $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])); @@ -997,10 +989,10 @@ LONG_STRING; 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'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:4','bare_id'=>4,'auxcounter'=>0,'name'=>'Photography','items'=>[],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(0 feeds)',],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; - $this->assertEquals($this->respGood($exp), $this->req($in[0])); - $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['id'=>'CAT:-1','items'=>[['id'=>'FEED:-4','name'=>'All articles','unread'=>35,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/folder.png','bare_id'=>-4,'auxcounter'=>0,],['id'=>'FEED:-3','name'=>'Fresh articles','unread'=>7,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/fresh.png','bare_id'=>-3,'auxcounter'=>0,],['id'=>'FEED:-1','name'=>'Starred articles','unread'=>4,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/star.png','bare_id'=>-1,'auxcounter'=>0,],['id'=>'FEED:-2','name'=>'Published articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/feed.png','bare_id'=>-2,'auxcounter'=>0,],['id'=>'FEED:0','name'=>'Archived articles','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/archive.png','bare_id'=>0,'auxcounter'=>0,],['id'=>'FEED:-6','name'=>'Recently read','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/time.png','bare_id'=>-6,'auxcounter'=>0,],],'name'=>'Special','type'=>'category','unread'=>0,'bare_id'=>-1,],['id'=>'CAT:-2','items'=>[['id'=>'FEED:-1027','name'=>'Fascinating','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1027,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1029','name'=>'Interesting','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1029,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],['id'=>'FEED:-1025','name'=>'Logical','unread'=>0,'type'=>'feed','error'=>'','updated'=>'','icon'=>'images/label.png','bare_id'=>-1025,'auxcounter'=>0,'fg_color'=>'','bg_color'=>'',],],'name'=>'Labels','type'=>'category','unread'=>6,'bare_id'=>-2,],['id'=>'CAT:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Politics','items'=>[['id'=>'CAT:5','bare_id'=>5,'name'=>'Local','items'=>[['id'=>'FEED:2','bare_id'=>2,'auxcounter'=>0,'name'=>'Toronto Star','checkbox'=>false,'unread'=>0,'error'=>'oops','icon'=>'feed-icons/2.ico','param'=>'2011-11-11T11:11:11',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(1 feed)',],['id'=>'CAT:6','bare_id'=>6,'name'=>'National','items'=>[['id'=>'FEED:4','bare_id'=>4,'auxcounter'=>0,'name'=>'CBC News','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>'feed-icons/4.ico','param'=>'2017-10-09T15:58:34',],['id'=>'FEED:5','bare_id'=>5,'auxcounter'=>0,'name'=>'Ottawa Citizen','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-07-07T17:07:17',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>3,'param'=>'(2 feeds)',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(3 feeds)',],['id'=>'CAT:1','bare_id'=>1,'auxcounter'=>0,'name'=>'Science','items'=>[['id'=>'CAT:2','bare_id'=>2,'name'=>'Rocketry','items'=>[['id'=>'FEED:1','bare_id'=>1,'auxcounter'=>0,'name'=>'NASA JPL','checkbox'=>false,'unread'=>0,'error'=>'','icon'=>false,'param'=>'2017-09-15T22:54:16',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'auxcounter'=>0,'parent_id'=>1,'param'=>'(1 feed)',],['id'=>'FEED:3','bare_id'=>3,'auxcounter'=>0,'name'=>'Ars Technica','checkbox'=>false,'unread'=>0,'error'=>'argh','icon'=>'feed-icons/3.ico','param'=>'2016-05-23T06:40:02',],],'checkbox'=>false,'type'=>'category','unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(2 feeds)',],['id'=>'CAT:0','bare_id'=>0,'auxcounter'=>0,'name'=>'Uncategorized','items'=>[['id'=>'FEED:6','bare_id'=>6,'auxcounter'=>0,'name'=>'Eurogamer','checkbox'=>false,'error'=>'','icon'=>'feed-icons/6.ico','param'=>'2010-02-12T20:08:47','unread'=>0,],],'type'=>'category','checkbox'=>false,'unread'=>0,'child_unread'=>0,'parent_id'=>null,'param'=>'(1 feed)',],],],]; - $this->assertEquals($this->respGood($exp), $this->req($in[1])); + $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() { @@ -1285,7 +1277,7 @@ LONG_STRING; $this->respErr("INCORRECT_USAGE"), ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertEquals($out[$a], $this->req($in[$a]), "Test $a failed"); + $this->assertResponse($out[$a], $this->req($in[$a]), "Test $a failed"); } } @@ -1309,10 +1301,10 @@ LONG_STRING; 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->assertEquals($exp, $this->req($in[0])); - $this->assertEquals($exp, $this->req($in[1])); - $this->assertEquals($exp, $this->req($in[2])); - $this->assertEquals($exp, $this->req($in[3])); + $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, @@ -1368,13 +1360,13 @@ LONG_STRING; 'content' => '

Article content 2

', ], ]; - $this->assertEquals($this->respGood($exp), $this->req($in[4])); - $this->assertEquals($this->respGood([$exp[0]]), $this->req($in[5])); - $this->assertEquals($this->respGood([$exp[1]]), $this->req($in[6])); + $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->assertEquals($this->respGood([$exp[0]]), $this->req($in[5])); + $this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); } public function testRetrieveCompactHeadlines() { @@ -1453,13 +1445,13 @@ LONG_STRING; $this->respGood([['id' => 1003]]), ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertEquals($out1[$a], $this->req($in1[$a]), "Test $a failed"); + $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->assertEquals($out2[$a], $this->req($in2[$a]), "Test $a failed"); + $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1564,13 +1556,13 @@ LONG_STRING; $this->assertResponse($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - $this->assertEquals($out2[$a], $this->req($in2[$a]), "Test $a failed"); + $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->assertEquals($out3[$a], $this->req($in3[$a]), "Test $a failed"); + $this->assertResponse($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1600,7 +1592,7 @@ LONG_STRING; 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->assertEquals($this->outputHeadlines(1), $test); + $this->assertResponse($this->outputHeadlines(1), $test); // test 'show_content' $test = $this->req($in[1]); $this->assertArrayHasKey("content", $test->payload['content'][0]); @@ -1632,7 +1624,7 @@ LONG_STRING; ['id' => -4, 'is_cat' => false, 'first_id' => 1], $exp->payload['content'], ]; - $this->assertEquals($exp, $test); + $this->assertResponse($exp, $test); // test 'include_header' with a category $test = $this->req($in[4]); $exp = $this->outputHeadlines(1); @@ -1640,14 +1632,14 @@ LONG_STRING; ['id' => -3, 'is_cat' => true, 'first_id' => 1], $exp->payload['content'], ]; - $this->assertEquals($exp, $test); + $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->assertEquals($exp, $test); + $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]); @@ -1655,7 +1647,7 @@ LONG_STRING; ['id' => 2112, 'is_cat' => false, 'first_id' => 0], [], ]); - $this->assertEquals($exp, $test); + $this->assertResponse($exp, $test); // test 'include_header' with ascending order $test = $this->req($in[7]); $exp = $this->outputHeadlines(1); @@ -1663,7 +1655,7 @@ LONG_STRING; ['id' => -4, 'is_cat' => false, 'first_id' => 0], $exp->payload['content'], ]; - $this->assertEquals($exp, $test); + $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]); @@ -1672,7 +1664,7 @@ LONG_STRING; ['id' => 42, 'is_cat' => false, 'first_id' => 1867], $exp->payload['content'], ]; - $this->assertEquals($exp, $test); + $this->assertResponse($exp, $test); // test 'include_header' with skip and ascending order $test = $this->req($in[9]); $exp = $this->outputHeadlines(1); @@ -1680,7 +1672,7 @@ LONG_STRING; ['id' => 42, 'is_cat' => false, 'first_id' => 0], $exp->payload['content'], ]; - $this->assertEquals($exp, $test); + $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…"; From 40e9b7f986c9ab3dde57f9db72f2fab4f052427a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 29 Nov 2017 11:47:10 -0500 Subject: [PATCH 65/66] Changed all TTRSS outputs to match original types exactly; improves #125 --- lib/REST/TinyTinyRSS/API.php | 39 +++-- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 162 +++++++++++---------- 2 files changed, 105 insertions(+), 96 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 406ef6d..d331121 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -227,7 +227,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out += $sub['unread']; } - return ['unread' => $out]; + return ['unread' => (string) $out]; // string cast to be consistent with TTRSS } public function opGetCounters(array $data): array { @@ -254,7 +254,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { foreach (Arsse::$db->subscriptionList($user) as $f) { if ($f['unread']) { // add the feed to the list of feeds - $feeds[] = ['id' => $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; + $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 @@ -492,6 +492,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $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; @@ -547,6 +548,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // 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", @@ -563,7 +568,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'parent' => $data['parent_id'], ]; try { - return Arsse::$db->folderAdd(Arsse::$user->id, $in); + 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 @@ -571,7 +576,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false); foreach ($folders as $folder) { if ($folder['name']==$in['name']) { - return (int) $folder['id']; + return (string) ((int) $folder['id']); // output is a string in TTRSS } } return false; // @codeCoverageIgnore @@ -663,7 +668,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => $this->labelOut($l['id']), 'title' => $l['name'], - 'unread' => $l['unread'], + 'unread' => (string) $l['unread'], // the unread count of labels is output as a string in TTRSS 'cat_id' => self::CAT_LABELS, ]; } @@ -681,7 +686,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_STARRED, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"), - 'unread' => $starred, + 'unread' => (string) $starred, // output is a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -689,7 +694,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_PUBLISHED, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Published"), - 'unread' => $published, + 'unread' => (string) $published, // output is a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -697,7 +702,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_FRESH, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"), - 'unread' => $fresh, + 'unread' => (string) $fresh, // output is a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -705,7 +710,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_ALL, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.All"), - 'unread' => $global, + 'unread' => (string) $global, // output is a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -713,7 +718,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_READ, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Read"), - 'unread' => 0, // zero by definition + 'unread' => 0, // zero by definition; this one is -NOT- a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -721,7 +726,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'id' => self::FEED_ARCHIVED, 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"), - 'unread' => $archived, + 'unread' => (string) $archived, // output is a string in TTRSS 'cat_id' => self::CAT_SPECIAL, ]; } @@ -1170,7 +1175,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out = []; foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)) as $article) { $out[] = [ - 'id' => $article['id'], + 'id' => (string) $article['id'], // string cast to be consistent with TTRSS 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null, 'title' => $article['title'], 'link' => $article['url'], @@ -1181,16 +1186,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'comments' => "", // FIXME: What is this? 'author' => $article['author'], 'updated' => Date::transform($article['edited_date'], "unix", "sql"), - 'feed_id' => $article['subscription'], + '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' => $article['id'], + '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, @@ -1264,7 +1270,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'author' => $article['author'], 'updated' => Date::transform($article['edited_date'], "unix", "sql"), 'is_updated' => ($article['published_date'] < $article['edited_date']), - 'feed_id' => $article['subscription'], + '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, @@ -1286,13 +1292,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } 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' => $article['id'], + 'post_id' => (string) $article['id'], // string cast to be consistent with TTRSS ]] : []; // TODO: We need to support multiple enclosures } $out[] = $row; diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 9842b8f..39542ff 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -311,14 +311,14 @@ LONG_STRING; 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); + $exp = $this->respGood("2"); $this->assertResponse($exp, $this->req($in[0])); - $exp = $this->respGood(3); + $exp = $this->respGood("3"); $this->assertResponse($exp, $this->req($in[1])); // attempt to add the two folders again - $exp = $this->respGood(2); + $exp = $this->respGood("2"); $this->assertResponse($exp, $this->req($in[0])); - $exp = $this->respGood(3); + $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); @@ -633,7 +633,7 @@ LONG_STRING; ['id' => 2, 'unread' => 42], ['id' => 3, 'unread' => 47], ])); - $exp = $this->respGood(['unread' => (2112 + 42 + 47)]); + $exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]); $this->assertResponse($exp, $this->req($in)); } @@ -808,54 +808,54 @@ LONG_STRING; 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' => "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' => "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' => "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' => "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' => 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], + ['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++) { @@ -880,11 +880,11 @@ LONG_STRING; ['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' => "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], @@ -1096,24 +1096,24 @@ LONG_STRING; ['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' => -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' => -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' => -1025, 'title' => "Logical", 'unread' => "0", 'cat_id' => -2], ], [ - ['id' => -1027, 'title' => "Fascinating", 'unread' => 6, '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], @@ -1131,14 +1131,14 @@ LONG_STRING; ['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' => -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], @@ -1147,10 +1147,10 @@ LONG_STRING; ['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' => -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], @@ -1307,7 +1307,7 @@ LONG_STRING; $this->assertResponse($exp, $this->req($in[3])); $exp = [ [ - 'id' => 101, + 'id' => "101", 'guid' => null, 'title' => 'Article title 1', 'link' => 'http://example.com/1', @@ -1318,7 +1318,7 @@ LONG_STRING; 'comments' => "", 'author' => '', 'updated' => strtotime('2000-01-01 00:00:01'), - 'feed_id' => 8, + 'feed_id' => "8", 'feed_title' => "Feed 11", 'attachments' => [], 'score' => 0, @@ -1327,7 +1327,7 @@ LONG_STRING; 'content' => '

Article content 1

', ], [ - 'id' => 102, + 'id' => "102", 'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7", 'title' => 'Article title 2', 'link' => 'http://example.com/2', @@ -1341,17 +1341,18 @@ LONG_STRING; 'comments' => "", 'author' => "J. King", 'updated' => strtotime('2000-01-02 00:00:02'), - 'feed_id' => 8, + '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, + 'post_id' => "102", ], ], 'score' => 0, @@ -1604,13 +1605,14 @@ LONG_STRING; $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, + 'post_id' => "2112", ], ]; $this->assertArrayHasKey("attachments", $test->payload['content'][0]); @@ -1742,7 +1744,7 @@ LONG_STRING; 'author' => '', 'updated' => strtotime('2000-01-01 00:00:00'), 'is_updated' => false, - 'feed_id' => 12, + 'feed_id' => "12", 'feed_title' => "Feed 2112", 'score' => 0, 'note' => null, @@ -1766,7 +1768,7 @@ LONG_STRING; 'author' => "J. King", 'updated' => strtotime('2000-01-02 00:00:02'), 'is_updated' => true, - 'feed_id' => 8, + 'feed_id' => "8", 'feed_title' => "Feed 11", 'score' => 0, 'note' => "Note 2", From 2bbc83aeb0452175d2a758ac2b75111f70fa1a18 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 29 Nov 2017 12:15:37 -0500 Subject: [PATCH 66/66] Tweak --- lib/REST/TinyTinyRSS/API.php | 4 ++-- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d331121..4dd60da 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -38,12 +38,11 @@ Protocol difference so far: - Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent - Article hashes are SHA-256 rather than SHA-1. - Articles have at most one attachment (enclosure), whereas TTRSS allows for several; there is also significantly less detail. These are limitations of picoFeed which should be addressed - - IDs for enclosures are ommitted as we don't give them IDs + - IDs for enclosures are always 0 as we don't give them IDs - Searching in getHeadlines is not yet implemented - Category -3 (all non-special feeds) is handled correctly in getHeadlines; TT-RSS returns results for feed -3 (Fresh) - Sorting of headlines does not match TT-RSS: special feeds are not sorted specially like they should be - The 'sanitize', 'force_update', and 'has_sandbox' parameters of getHeadlines are ignored - - The 'always_display_attachments' key of articles in getHeadlines is omitted, as the user cannot express a preference */ @@ -1278,6 +1277,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { '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']; diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 39542ff..4d04546 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -1752,6 +1752,7 @@ LONG_STRING; 'tags' => [], 'comments_count' => 0, 'comments_link' => "", + 'always_display_attachments' => false, ], [ 'id' => 2112, @@ -1776,6 +1777,7 @@ LONG_STRING; 'tags' => ["Boring", "Illogical"], 'comments_count' => 0, 'comments_link' => "", + 'always_display_attachments' => false, ], ]); }