diff --git a/CHANGELOG b/CHANGELOG index c2ce3366..b0b42f4b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,10 +5,12 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords +- Command line functionality for exporting subscriptions to OPML - Command line documentation of all commands and options Bug fixes: - Treat command line option -h the same as --help +- Sort Tiny Tiny RSS special feeds according to special ordering Version 0.7.1 (2019-03-25) ========================== diff --git a/README.md b/README.md index 08dab43e..f568a4ac 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` -- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds - The `getCounters` operation normally omits members with zero unread; The Arsse includes everything to appease some clients #### Other notes diff --git a/UPGRADING b/UPGRADING index a837396d..ea3c84ad 100644 --- a/UPGRADING +++ b/UPGRADING @@ -10,6 +10,12 @@ usually prudent: - If installing from source, update dependencies with: `composer install -o --no-dev` +Upgrading from 0.7.1 to 0.8.0 +============================= + +- The database schema has changed from rev4 to rev5; if upgrading the database + manually, apply the 4.sql file + Upgrading from 0.5.1 to 0.6.0 ============================= diff --git a/arsse.php b/arsse.php index 407be037..0cfa0ae4 100644 --- a/arsse.php +++ b/arsse.php @@ -25,7 +25,7 @@ if (\PHP_SAPI === "cli") { $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf; Arsse::load($conf); // handle Web requests - $emitter = new \Zend\Diactoros\Response\SapiEmitter(); + $emitter = new \Zend\HttpHandlerRunner\Emitter\SapiEmitter; $response = (new REST)->dispatch(); $emitter->emit($response); } diff --git a/composer.json b/composer.json index 0669d657..3fdd1c71 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,17 @@ ], "require": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*", "ext-dom": "*", "p3k/picofeed": "0.1.*", - "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0", - "jkingweb/druuid": "^3.0", - "zendframework/zend-diactoros": "^1.6" + "hosteurope/password-generator": "1.*", + "docopt/docopt": "1.*", + "jkingweb/druuid": "3.*", + "zendframework/zend-diactoros": "2.*", + "zendframework/zend-httphandlerrunner": "1.*" }, "require-dev": { "bamarni/composer-bin-plugin": "*" diff --git a/composer.lock b/composer.lock index 986e47cc..54a74444 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f61a02cd168914d91847b89dcd00d464", + "content-hash": "c2b0698669d89268ffb995a5e1d6667a", "packages": [ { "name": "docopt/docopt", @@ -190,6 +190,58 @@ "homepage": "https://github.com/miniflux/picoFeed", "time": "2017-11-30T00:16:58+00:00" }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2019-04-30T12:38:16+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -241,39 +293,95 @@ "time": "2016-08-06T14:39:51+00:00" }, { - "name": "zendframework/zend-diactoros", - "version": "1.8.6", + "name": "psr/http-server-handler", + "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e" + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0", + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, + { + "name": "zendframework/zend-diactoros", + "version": "2.1.3", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-diactoros.git", + "reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/279723778c40164bcf984a2df12ff2c6ec5e61c1", + "reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0" }, "provide": { + "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "ext-dom": "*", "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.5.0", "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^5.7.16 || ^6.0.8 || ^7.2.7", - "zendframework/zend-coding-standard": "~1.0" + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev", - "dev-develop": "1.9.x-dev", - "dev-release-2.0": "2.0.x-dev" + "dev-master": "2.1.x-dev", + "dev-develop": "2.2.x-dev", + "dev-release-1.8": "1.8.x-dev" } }, "autoload": { @@ -293,16 +401,70 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-2-Clause" + "BSD-3-Clause" ], "description": "PSR HTTP Message implementations", - "homepage": "https://github.com/zendframework/zend-diactoros", "keywords": [ "http", "psr", "psr-7" ], - "time": "2018-09-05T19:29:37+00:00" + "time": "2019-07-10T16:13:25+00:00" + }, + { + "name": "zendframework/zend-httphandlerrunner", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-httphandlerrunner.git", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-httphandlerrunner/zipball/75fb12751fe9d6e392cce1ee0d687dacae2db787", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-message": "^1.0", + "psr/http-message-implementation": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0", + "zendframework/zend-diactoros": "^1.7 || ^2.1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev", + "dev-develop": "1.2.x-dev" + }, + "zf": { + "config-provider": "Zend\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Zend\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "keywords": [ + "ZendFramework", + "components", + "expressive", + "psr-15", + "psr-7", + "zf" + ], + "time": "2019-02-19T18:20:34+00:00" }, { "name": "zendframework/zendxml", @@ -398,7 +560,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*", diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 858409f6..fb1236a3 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\Context; class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; - public $reverse = false; public $limit = 0; public $offset = 0; public $unread; @@ -31,10 +30,6 @@ class Context extends ExclusionContext { unset($this->not); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function limit(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 1f91994a..e7323ea7 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -11,16 +11,23 @@ use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; + public $folders; public $folderShallow; + public $foldersShallow; public $tag; + public $tags; public $tagName; + public $tagNames; public $subscription; + public $subscriptions; public $edition; - public $article; public $editions; + public $article; public $articles; public $label; + public $labels; public $labelName; + public $labelNames; public $annotationTerms; public $searchTerms; public $titleTerms; @@ -42,6 +49,7 @@ class ExclusionContext { } public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone if ($this->parent) { $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { @@ -70,16 +78,18 @@ class ExclusionContext { } } - protected function cleanIdArray(array $spec): array { + protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { - if (ValueInfo::id($spec[$a])) { + if (ValueInfo::id($spec[$a], $allowZero)) { $spec[$a] = (int) $spec[$a]; } else { - $spec[$a] = 0; + $spec[$a] = null; } } - return array_values(array_unique(array_filter($spec))); + return array_values(array_unique(array_filter($spec, function($v) { + return !is_null($v); + }))); } protected function cleanStringArray(array $spec): array { @@ -99,22 +109,57 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function folders(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function folderShallow(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function foldersShallow(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tag(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tags(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tagName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tagNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function subscriptions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -141,10 +186,24 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labels(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function labelName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labelNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function annotationTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); diff --git a/lib/Database.php b/lib/Database.php index ef91591f..daca2344 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1239,6 +1239,37 @@ class Database { )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } + /** Returns an associative array of result column names and their SQL computations for article queries + * + * This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options + */ + protected function articleColumns(): array { + $greatest = $this->db->sqlToken("greatest"); + return [ + 'id' => "arsse_articles.id", + 'edition' => "latest_editions.edition", + 'url' => "arsse_articles.url", + 'title' => "arsse_articles.title", + 'author' => "arsse_articles.author", + 'content' => "arsse_articles.content", + 'guid' => "arsse_articles.guid", + 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", + 'subscription' => "arsse_subscriptions.id", + 'feed' => "arsse_subscriptions.feed", + 'starred' => "coalesce(arsse_marks.starred,0)", + 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", + 'note' => "coalesce(arsse_marks.note,'')", + 'published_date' => "arsse_articles.published", + 'edited_date' => "arsse_articles.edited", + 'modified_date' => "arsse_articles.modified", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", + 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", + 'media_url' => "arsse_enclosures.url", + 'media_type' => "arsse_enclosures.type", + ]; + } + /** Computes an SQL query to find and retrieve data about articles in the database * * If an empty column list is supplied, a count of articles matching the context is queried instead @@ -1271,48 +1302,30 @@ class Database { $this->labelValidateId($user, $context->labelName, true); } // prepare the output column list; the column definitions are also used later - $greatest = $this->db->sqlToken("greatest"); - $colDefs = [ - 'id' => "arsse_articles.id", - 'edition' => "latest_editions.edition", - 'url' => "arsse_articles.url", - 'title' => "arsse_articles.title", - 'author' => "arsse_articles.author", - 'content' => "arsse_articles.content", - 'guid' => "arsse_articles.guid", - 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", - 'folder' => "coalesce(arsse_subscriptions.folder,0)", - 'subscription' => "arsse_subscriptions.id", - 'feed' => "arsse_subscriptions.feed", - 'starred' => "coalesce(arsse_marks.starred,0)", - 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", - 'note' => "coalesce(arsse_marks.note,'')", - 'published_date' => "arsse_articles.published", - 'edited_date' => "arsse_articles.edited", - 'modified_date' => "arsse_articles.modified", - 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", - 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", - 'media_url' => "arsse_enclosures.url", - 'media_type' => "arsse_enclosures.type", - ]; + $colDefs = $this->articleColumns(); if (!$cols) { - // if no columns are specified return a count - $columns = "count(distinct arsse_articles.id) as count"; + // if no columns are specified return a count; don't borther with sorting + $outColumns = "count(distinct arsse_articles.id) as count"; } else { - $columns = []; + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); + }; + $cols = array_map($norm, $cols); + // make an output column list + $outColumns = []; foreach ($cols as $col) { - $col = trim(strtolower($col)); if (!isset($colDefs[$col])) { continue; } - $columns[] = $colDefs[$col]." as ".$col; + $outColumns[] = $colDefs[$col]." as ".$col; } - $columns = implode(",", $columns); + $outColumns = implode(",", $outColumns); } // define the basic query, to which we add lots of stuff where necessary $q = new Query( "SELECT - $columns + $outColumns from arsse_articles join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ? join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id @@ -1344,7 +1357,9 @@ class Database { "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], "folderShallow" => ["folder", "=", "int", ""], + "foldersShallow" => ["folder", "in", "int", ""], "subscription" => ["subscription", "=", "int", ""], + "subscriptions" => ["subscription", "in", "int", ""], "unread" => ["unread", "=", "bool", ""], "starred" => ["starred", "=", "bool", ""], ]; @@ -1395,6 +1410,79 @@ class Database { $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } + // handle labels and tags + $options = [ + 'label' => [ + 'match_col' => "arsse_articles.id", + 'cte_name' => "labelled", + 'cte_cols' => ["article", "label_id", "label_name"], + 'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'label' => ['use_name' => false, 'multi' => false], + 'labels' => ['use_name' => false, 'multi' => true], + 'labelName' => ['use_name' => true, 'multi' => false], + 'labelNames' => ['use_name' => true, 'multi' => true], + ], + ], + 'tag' => [ + 'match_col' => "arsse_subscriptions.id", + 'cte_name' => "tagged", + 'cte_cols' => ["subscription", "tag_id", "tag_name"], + 'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'tag' => ['use_name' => false, 'multi' => false], + 'tags' => ['use_name' => false, 'multi' => true], + 'tagName' => ['use_name' => true, 'multi' => false], + 'tagNames' => ['use_name' => true, 'multi' => true], + ], + ], + ]; + foreach ($options as $opt) { + $seen = false; + $match = $opt['match_col']; + $table = $opt['cte_name']; + foreach ($opt['options'] as $m => $props) { + $named = $props['use_name']; + $multi = $props['multi']; + $selection = $opt['cte_cols'][0]; + $col = $opt['cte_cols'][$named ? 2 : 1]; + if ($context->$m()) { + $seen = true; + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->$m; + } + $q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values); + } + if ($context->not->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->not->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->not->$m; + } + $q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values); + } + } + if ($seen) { + $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; + $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); + } + } // handle complex context options if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; @@ -1405,48 +1493,32 @@ class Database { $op = $context->labelled ? ">" : "="; $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } - if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { - $q->setCTE("labelled(article,label_id,label_name)", "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); - if ($context->label()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); - } - if ($context->not->label()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); - } - if ($context->labelName()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); - } - if ($context->not->labelName()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); - } - } - if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { - $q->setCTE("tagged(id,name,subscription)", "SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); - if ($context->tag()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); - } - if ($context->not->tag()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag); - } - if ($context->tagName()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName); - } - if ($context->not->tagName()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName); - } - } if ($context->folder()) { // add a common table expression to list the folder 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 = folder", "int", $context->folder); + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder); // limit subscriptions to the listed folders $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); } + if ($context->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)"); + } if ($context->not->folder()) { // add a common table expression to list the folder and its children so that we exclude from the entire subtree - $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder); + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); // excluded any subscriptions in the listed folders $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } + if ($context->not->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->not->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)"); + } // handle text-matching context options $options = [ "titleTerms" => ["arsse_articles.title"], @@ -1454,20 +1526,20 @@ class Database { "authorTerms" => ["arsse_articles.author"], "annotationTerms" => ["arsse_marks.note"], ]; - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->$m()) { continue; } elseif (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } - $q->setWhere(...$this->generateSearch($context->$m, $cols)); + $q->setWhere(...$this->generateSearch($context->$m, $columns)); } // further handle exclusionary text-matching context options - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->not->$m() || !$context->not->$m) { continue; } - $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); + $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); } // return the query return $q; @@ -1479,16 +1551,47 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance */ - public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { + public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } + // make a base query based on context and output columns $context = $context ?? new Context; $q = $this->articleQuery($user, $context, $fields); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // make an ORDER BY column list + $colDefs = $this->articleColumns(); + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower((string) $v)); + }; + $fields = array_map($norm, $fields); + $sort = array_map($norm, $sort); + foreach ($sort as $spec) { + $col = explode(" ", $spec, 2); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $fields)) { + // if the sort column is also an output column, use it as-is + $q->setOrder($col.$order); + } else { + // otherwise if the column name is valid, use its expression + $q->setOrder($colDefs[$col].$order); + } + } // perform the query and return results return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 47b4038f..83c3be82 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Context\Context; -use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; @@ -21,26 +21,54 @@ use JKingWeb\Arsse\REST\Exception405; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 3; + const GENERIC_ICON_TYPE = "image/png;base64"; + const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="; + + // GET parameters for which we only check presence: these will be converted to booleans + const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; + // GET parameters which contain meaningful values + const PARAM_GET = [ + 'api' => V::T_STRING, // this parameter requires special handling + 'page' => V::T_INT, // parameter for hot links + 'range' => V::T_INT, // parameter for hot links + 'offset' => V::T_INT, // parameter for hot links + 'since_id' => V::T_INT, + 'max_id' => V::T_INT, + 'with_ids' => V::T_STRING, + 'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + 'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + ]; + // POST parameters, all of which contain meaningful values + const PARAM_POST = [ + 'api_key' => V::T_STRING, + 'mark' => V::T_STRING, + 'as' => V::T_STRING, + 'id' => V::T_INT, + 'before' => V::T_DATE, + 'unread_recently_read' => V::T_BOOL, + ]; public function __construct() { } public function dispatch(ServerRequestInterface $req): ResponseInterface { - $inR = $req->getQueryParams() ?? []; - $inW = $req->getParsedBody() ?? []; - if (!array_key_exists("api", $inR)) { + $G = $this->normalizeInputGet($req->getQueryParams() ?? []); + $P = $this->normalizeInputPost($req->getParsedBody() ?? []); + if (!isset($G['api'])) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); } - $xml = $inR['api'] === "xml"; switch ($req->getMethod()) { case "OPTIONS": - // do stuff - break; + return new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); case "POST": if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); @@ -58,31 +86,87 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return new EmptyResponse(401); } // produce a full response if authenticated or a basic response otherwise - if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { - $out = $this->processRequest($this->baseResponse(true), $inR, $inW); + if ($this->logIn(strtolower($P['api_key'] ?? ""))) { + $out = $this->processRequest($this->baseResponse(true), $G, $P); } else { $out = $this->baseResponse(false); } // return the result, possibly formatted as XML - return $this->formatResponse($out, $xml); - break; + return $this->formatResponse($out, ($G['api'] === "xml")); default: return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); } } + protected function normalizeInputGet(array $data): array { + $out = []; + if (array_key_exists("api", $data)) { + // the "api" parameter must be handled specially as it a string, but null has special meaning + $data['api'] = $data['api'] ?? "json"; + } + foreach (self::PARAM_BOOL as $p) { + // first handle all the boolean parameters + $out[$p] = array_key_exists($p, $data); + } + foreach (self::PARAM_GET as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + + protected function normalizeInputPost(array $data): array { + $out = []; + foreach (self::PARAM_POST as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + protected function processRequest(array $out, array $G, array $P): array { - if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { - if (array_key_exists("groups", $G)) { + $listUnread = false; + $listSaved = false; + if ($P['unread_recently_read']) { + $this->setUnread(); + $listUnread = true; + } + if ($P['mark'] && $P['as'] && is_int($P['id'])) { + // depending on which mark are being made, + // either an 'unread_item_ids' or a + // 'saved_item_ids' entry will be added later + $listSaved = $this->setMarks($P, $listUnread); + } + if ($G['feeds'] || $G['groups']) { + if ($G['groups']) { $out['groups'] = $this->getGroups(); } - if (array_key_exists("feeds", $G)) { + if ($G['feeds']) { $out['feeds'] = $this->getFeeds(); } $out['feeds_groups'] = $this->getRelationships(); } - if (array_key_exists("favicons", $G)) { - # deal with favicons + if ($G['favicons']) { + // TODO: implement favicons properly + // we provide a single blank favicon for now + $out['favicons'] = [ + [ + 'id' => 0, + 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA, + ], + ]; + } + if ($G['items']) { + $out['items'] = $this->getItems($G); + $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id); + } + if ($G['links']) { + // TODO: implement hot links + $out['links'] = []; + } + if ($G['unread_item_ids'] || $listUnread) { + $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); + } + if ($G['saved_item_ids'] || $listSaved) { + $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); } return $out; } @@ -101,12 +185,49 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { - throw \Exception("Not implemented yet"); + $d = new \DOMDocument("1.0", "utf-8"); + $d->appendChild($this->makeXMLAssoc($data, $d->createElement("response"))); + return new XmlResponse($d->saveXML()); } else { return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); } } + protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $k => $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, (string) $v)); + } elseif (isset($v[0])) { + // this is a very simplistic check for an indexed array + // it would not pass muster in the face of generic data, + // but we'll assume our code produces only well-ordered + // indexed arrays + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + } + + protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $v) { + if (!is_array($v)) { + // this case is never encountered with Fever's output + $p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore + } elseif (isset($v[0])) { + // this case is never encountered with Fever's output + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + + } + protected function logIn(string $hash): bool { // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { @@ -123,6 +244,80 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } + protected function setMarks(array $P, &$listUnread): bool { + $listSaved = false; + $c = new Context; + $id = $P['id']; + if ($P['before']) { + $c->notMarkedSince($P['before']); + } + switch ($P['mark']) { + case "item": + $c->article($id); + break; + case "group": + if ($id > 0) { + // concrete groups + $c->tag($id); + } elseif ($id < 0) { + // group negative-one is the "Sparks" supergroup i.e. no feeds + $c->not->folder(0); + } else { + // group zero is the "Kindling" supergroup i.e. all feeds + // nothing need to be done for this + } + break; + case "feed": + $c->subscription($id); + break; + default: + return $listSaved; + } + switch ($P['as']) { + case "read": + $data = ['read' => true]; + $listUnread = true; + break; + case "unread": + // this option is undocumented, but valid + $data = ['read' => false]; + $listUnread = true; + break; + case "saved": + $data = ['starred' => true]; + $listSaved = true; + break; + case "unsaved": + $data = ['starred' => false]; + $listSaved = true; + break; + default: + return $listSaved; + } + try { + Arsse::$db->articleMark(Arsse::$user->id, $data, $c); + } catch (ExceptionInput $e) { + // ignore any errors + } + return $listSaved; + } + + protected function setUnread() { + $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue(); + if (!$lastUnread) { + // there are no articles + return; + } + // Fever takes the date of the last read article less fifteen seconds as a cut-off. + // We take the date of last mark (whether it be read, unread, saved, unsaved), which + // may not actually signify a mark, but we'll otherwise also count back fifteen seconds + $c = new Context; + $lastUnread = Date::normalize($lastUnread, "sql"); + $since = Date::sub("PT15S", $lastUnread); + $c->unread(false)->markedSince($since); + Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); + } + protected function getRefreshTime() { return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); } @@ -132,7 +327,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out[] = [ 'id' => (int) $sub['id'], - 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'favicon_id' => 0, // TODO: implement favicons 'title' => (string) $sub['title'], 'url' => $sub['url'], 'site_url' => $sub['source'], @@ -171,4 +366,50 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + protected function getItems(array $G): array { + $c = (new Context)->limit(50); + $reverse = false; + // handle the standard options + if ($G['with_ids']) { + $c->articles(explode(",", $G['with_ids'])); + } elseif ($G['max_id']) { + $c->latestArticle($G['max_id'] - 1); + $reverse = true; + } elseif ($G['since_id']) { + $c->oldestArticle($G['since_id'] + 1); + } + // handle the undocumented options + if ($G['group_ids']) { + $c->tags(explode(",", $G['group_ids'])); + } + if ($G['feed_ids']) { + $c->subscriptions(explode(",", $G['feed_ids'])); + } + // get results + $out = []; + $order = $reverse ? "id desc" : "id"; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) { + $out[] = [ + 'id' => (int) $r['id'], + 'feed_id' => (int) $r['subscription'], + 'title' => (string) $r['title'], + 'author' => (string) $r['author'], + 'html' => (string) $r['content'], + 'url' => (string) $r['url'], + 'is_saved' => (int) $r['starred'], + 'is_read' => (int) !$r['unread'], + 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), + ]; + } + return $out; + } + + protected function getItemIds(Context $c = null): string { + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { + $out[] = (int) $r['id']; + } + return implode(",", $out); + } } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 7f4301c8..0df5032d 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -521,14 +521,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $c->limit($data['batchSize']); } // set the order of returned items - if ($data['oldestFirst']) { - $c->reverse(false); - } else { - $c->reverse(true); - } + $reverse = !$data['oldestFirst']; // 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 ($data['offset'] > 0) { - if ($c->reverse) { + if ($reverse) { $c->latestEdition($data['offset'] - 1); } else { $c->oldestEdition($data['offset'] + 1); @@ -579,7 +575,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { "starred", "modified_date", "fingerprint", - ]); + ], [$reverse ? "edition desc" : "edition"]); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid return new EmptyResponse(422); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d29c0cf4..045710cf 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -8,11 +8,10 @@ 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; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; @@ -1439,7 +1438,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // 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 + $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one break; default: // any actual feed @@ -1492,15 +1491,15 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { switch ($data['order_by']) { case "date_reverse": // sort oldest first - $c->reverse(false); + $order = ["edited_date"]; break; case "feed_dates": // sort newest first - $c->reverse(true); + $order = ["edited_date desc"]; 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); + // sort most recently marked for special feeds, newest first otherwise + $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } // set the limit and offset @@ -1515,6 +1514,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c->oldestArticle($data['since_id'] + 1); } // return results - return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order); } } diff --git a/robo b/robo index 0b3be08f..f5259416 100755 --- a/robo +++ b/robo @@ -5,7 +5,7 @@ shift ulimit -n 2048 if [ "$1" = "clean" ]; then - "$base/vendor/bin/robo" "$roboCommand" $* + "$base/vendor/bin/robo" "$roboCommand" "$@" else - "$base/vendor/bin/robo" "$roboCommand" -- $* + "$base/vendor/bin/robo" "$roboCommand" -- "$@" fi diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 2deaeb75..75fba45c 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Phake; trait SeriesArticle { @@ -424,10 +425,15 @@ trait SeriesArticle { return [ 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], 'Leaf folder' => [(new Context)->folder(6), [7,8]], - 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Multiple folder trees' => [(new Context)->folders([1,5]), [5,6,7,8,19,20]], + 'Multiple folder trees including root' => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], 'Read' => [(new Context)->subscription(5)->unread(false), [19]], 'Starred' => [(new Context)->starred(true), [1,20]], @@ -455,11 +461,12 @@ trait SeriesArticle { 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], - 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'With label "Interesting" or "Fascinating"' => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]], 'Article ID 20' => [(new Context)->article(20), [20]], 'Edition ID 1001' => [(new Context)->edition(1001), [20]], 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], @@ -494,12 +501,19 @@ trait SeriesArticle { 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'With tag "Technology" or "Politics"' => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]], 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding tags ID 1 and 5' => [(new Context)->not->tags([1,5]), [1,2,3,4]], + 'Excluding tags "Technology" and "Politics"' => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]], + 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], + 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], + 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], ]; } @@ -563,6 +577,25 @@ trait SeriesArticle { $this->assertEquals($this->fields, $test); } + /** @dataProvider provideOrderedLists */ + public function testListArticlesCheckingOrder(array $sortCols, array $exp) { + $act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY); + $this->assertSame($exp, $act); + } + + public function provideOrderedLists() { + return [ + [["id"], [1,2,3,4,5,6,7,8,19,20]], + [["id asc"], [1,2,3,4,5,6,7,8,19,20]], + [["id desc"], [20,19,8,7,6,5,4,3,2,1]], + [["edition"], [1,2,3,4,5,6,7,8,19,20]], + [["edition asc"], [1,2,3,4,5,6,7,8,19,20]], + [["edition desc"], [20,19,8,7,6,5,4,3,2,1]], + [["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]], + [["id", "editio"], [1,2,3,4,5,6,7,8,19,20]], + ]; + } + public function testListArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); @@ -783,11 +816,6 @@ trait SeriesArticle { $this->compareExpectations(static::$drv, $state); } - public function testMarkTooFewMultipleArticles() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); - } - public function testMarkTooManyMultipleArticles() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } @@ -854,11 +882,6 @@ trait SeriesArticle { $this->compareExpectations(static::$drv, $state); } - public function testMarkTooFewMultipleEditions() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); - } - public function testMarkTooManyMultipleEditions() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)))); } @@ -1030,13 +1053,20 @@ trait SeriesArticle { Arsse::$db->articleCategoriesGet($this->user, 19); } - public function testSearchTooFewTerms() { + /** @dataProvider provideArrayContextOptions */ + public function testUseTooFewValuesInArrayContext(string $option) { $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); + Arsse::$db->articleList($this->user, (new Context)->$option([])); } - public function testSearchTooFewTermsInNote() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + public function provideArrayContextOptions() { + foreach ([ + "articles", "editions", + "subscriptions", "foldersShallow", //"folders", + "tags", "tagNames", "labels", "labelNames", + "searchTerms", "authorTerms", "annotationTerms", + ] as $method) { + yield [$method]; + } } } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index e85d58ec..f32f11e4 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -29,10 +29,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folders' => [12,22], 'folderShallow' => 42, + 'foldersShallow' => [0,1], 'tag' => 44, + 'tags' => [44, 2112], 'tagName' => "XLIV", + 'tagNames' => ["XLIV", "MMCXII"], 'subscription' => 2112, + 'subscriptions' => [44, 2112], 'article' => 255, 'edition' => 65535, 'latestArticle' => 47, @@ -48,7 +53,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'editions' => [1,2], 'articles' => [1,2], 'label' => 2112, + 'labels' => [2112, 1984], 'labelName' => "Rush", + 'labelNames' => ["Rush", "Orwell"], 'labelled' => true, 'annotated' => true, 'searchTerms' => ["foo", "bar"], @@ -79,9 +86,19 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanIdArrayValues() { - $methods = ["articles", "editions"]; - $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; - $out = [1,2, 3]; + $methods = ["articles", "editions", "tags", "labels", "subscriptions"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCleanFolderIdArrayValues() { + $methods = ["folders", "foldersShallow"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4, 0]; $c = new Context; foreach ($methods as $method) { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); @@ -89,7 +106,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms", "tagNames", "labelNames"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 1986db07..3393ddeb 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -7,37 +7,153 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\Fever; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\User\Exception as UserException; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + /** @var \JKingWeb\Arsse\REST\Fever\API */ + protected $h; + + protected $articles = [ + 'db' => [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'published_date' => '2000-01-01 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + 'content' => '

Article content 2

', + 'published_date' => '2000-01-02 00:00:00', + 'unread' => 0, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'published_date' => '2000-01-03 00:00:00', + 'unread' => 1, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'published_date' => '2000-01-04 00:00:00', + 'unread' => 0, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'published_date' => '2000-01-05 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 10, + ], + ], + 'rest' => [ + [ + 'id' => 101, + 'feed_id' => 8, + 'title' => 'Article title 1', + 'author' => '', + 'html' => '

Article content 1

', + 'url' => 'http://example.com/1', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 946684800, + ], + [ + 'id' => 102, + 'feed_id' => 8, + 'title' => 'Article title 2', + 'author' => '', + 'html' => '

Article content 2

', + 'url' => 'http://example.com/2', + 'is_saved' => 0, + 'is_read' => 1, + 'created_on_time' => 946771200, + ], + [ + 'id' => 103, + 'feed_id' => 9, + 'title' => 'Article title 3', + 'author' => '', + 'html' => '

Article content 3

', + 'url' => 'http://example.com/3', + 'is_saved' => 1, + 'is_read' => 0, + 'created_on_time' => 946857600, + ], + [ + 'id' => 104, + 'feed_id' => 9, + 'title' => 'Article title 4', + 'author' => '', + 'html' => '

Article content 4

', + 'url' => 'http://example.com/4', + 'is_saved' => 1, + 'is_read' => 1, + 'created_on_time' => 946944000, + ], + [ + 'id' => 105, + 'feed_id' => 10, + 'title' => 'Article title 5', + 'author' => '', + 'html' => '

Article content 5

', + 'url' => 'http://example.com/5', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 947030400, + ], + ], + ]; protected function v($value) { return $value; } - protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { $url = "/fever/".$url; + $type = $type ?? "application/x-www-form-urlencoded"; $server = [ 'REQUEST_METHOD' => $method, 'REQUEST_URI' => $url, - 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", + 'HTTP_CONTENT_TYPE' => $type, ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); + $req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); if (!is_array($dataGet)) { parse_str($dataGet, $dataGet); } @@ -45,9 +161,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { if (is_array($dataPost)) { $req = $req->withParsedBody($dataPost); } else { - $body = $req->getBody(); - $body->write($dataPost); - $req = $req->withBody($body); + parse_str($dataPost, $arr); + $req = $req->withParsedBody($arr); } if (isset($user)) { if (strlen($user)) { @@ -56,7 +171,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $req = $req->withAttribute("authenticationFailed", true); } } - return $this->h->dispatch($req); + return $req; } public function setUp() { @@ -95,7 +210,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { return $out; }); - $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); + $act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); $this->assertMessage($exp, $act); } @@ -174,7 +289,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&groups"); + $act = $this->h->dispatch($this->req("api&groups")); $this->assertMessage($exp, $act); } @@ -192,16 +307,208 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ])); $exp = new JsonResponse([ 'feeds' => [ - ['id' => 1, 'favicon_id' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], + ['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")], - ['id' => 3, 'favicon_id' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], + ['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], ], 'feeds_groups' => [ ['group_id' => 1, 'feed_ids' => "1,2"], ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&feeds"); + $act = $this->h->dispatch($this->req("api&feeds")); + $this->assertMessage($exp, $act); + } + + /** @dataProvider provideItemListContexts */ + public function testListItems(string $url, Context $c, bool $desc) { + $fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; + $order = [$desc ? "id desc" : "id"]; + \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db'])); + \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024); + $exp = new JsonResponse([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $act = $this->h->dispatch($this->req("api&$url")); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order); + } + + public function provideItemListContexts() { + $c = (new Context)->limit(50); + return [ + ["items", (clone $c), false], + ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false], + ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false], + ["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], + ["items&since_id=1", (clone $c)->oldestArticle(2), false], + ["items&max_id=2", (clone $c)->latestArticle(1), true], + ["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true], + ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], + ]; + } + + public function testListItemIds() { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + $exp = new JsonResponse([ + 'saved_item_ids' => "1,2,3" + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids"))); + $exp = new JsonResponse([ + 'unread_item_ids' => "4,5,6" + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids"))); + } + + public function testListHotLinks() { + // hot links are not actually implemented, so an empty array should be all we get + $exp = new JsonResponse([ + 'links' => [] + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&links"))); + } + + /** @dataProvider provideMarkingContexts */ + public function testSetMarks(string $post, Context $c, array $data, array $out) { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + $exp = new JsonResponse($out); + $act = $this->h->dispatch($this->req("api", $post)); + $this->assertMessage($exp, $act); + if ($c && $data) { + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideMarkingContexts() { + $markRead = ['read' => true]; + $markUnread = ['read' => false]; + $markSaved = ['starred' => true]; + $markUnsaved = ['starred' => false]; + $listSaved = ['saved_item_ids' => "1,2,3"]; + $listUnread = ['unread_item_ids' => "4,5,6"]; + return [ + ["mark=item&as=read&id=5", (new Context)->article(5), $markRead, $listUnread], + ["mark=item&as=unread&id=42", (new Context)->article(42), $markUnread, $listUnread], + ["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist + ["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved], + ["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved], + ["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread], + ["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread], + ["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved], + ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved], + ["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread], + ["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread], + ["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved], + ["mark=item&as=invalid&id=42", new Context, [], []], + ["mark=invalid&as=unread&id=42", new Context, [], []], + ["mark=group&as=read&id=0", (new Context), $markRead, $listUnread], + ["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread], + ["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread], + ["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], + ["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], + ["mark=item&as=unread", new Context, [], []], + ["mark=item&id=6", new Context, [], []], + ["as=unread&id=6", new Context, [], []], + ]; + } + + /** @dataProvider provideInvalidRequests */ + public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp) { + $this->assertMessage($exp, $this->h->dispatch($req)); + } + + public function provideInvalidRequests() { + return [ + 'Not an API request' => [$this->req(""), new EmptyResponse(404)], + 'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], + 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], + ]; + } + + public function testMakeABaseQuery() { + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->logIn->thenReturn(true); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z")); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => 946684800, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => null, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when($this->h)->logIn->thenReturn(false); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 0, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + } + + public function testUndoReadMarks() { + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + $out = ['unread_item_ids' => "4,5,6"]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]])); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + $exp = new JsonResponse($out); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([])); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark; // only called one time, above + } + + public function testOutputToXml() { + \Phake::when($this->h)->processRequest->thenReturn([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $exp = new XmlResponse("1018Article title 1<p>Article content 1</p>http://example.com/1009466848001028Article title 2<p>Article content 2</p>http://example.com/2019467712001039Article title 3<p>Article content 3</p>http://example.com/3109468576001049Article title 4<p>Article content 4</p>http://example.com/41194694400010510Article title 5<p>Article content 5</p>http://example.com/5009470304001024"); + $act = $this->h->dispatch($this->req("api=xml")); + $this->assertMessage($exp, $act); + } + + public function testListFeedIcons() { + $act = $this->h->dispatch($this->req("api&favicons")); + $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); + $this->assertMessage($exp, $act); + } + + public function testAnswerOptionsRequest() { + $act = $this->h->dispatch($this->req("api", "", "OPTIONS")); + $exp = new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); $this->assertMessage($exp, $act); } } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 664db4e1..52291cb9 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -734,11 +734,11 @@ class TestV1_2 extends \JKingWeb\Arsse\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(), $this->anything())->thenReturn(new Result($this->v($this->articles['db']))); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($this->articles['db']))); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context @@ -759,17 +759,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $this->req("GET", "/items", json_encode($in[10])); $this->req("GET", "/items", json_encode($in[11])); // perform method verifications - Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), $this->anything()); // offset is one more than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), $this->anything()); // offset is one less than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->reverse(true)->markedSince($t), 2), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), $this->anything()); + Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, new Context, $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(10)->oldestEdition(6), $this->anything(), ["edition"]); // offset is one more than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5)->latestEdition(4), $this->anything(), ["edition desc"]); // offset is one less than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->markedSince($t), 2), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5), $this->anything(), ["edition desc"]); } public function testMarkAFolderRead() { @@ -958,6 +958,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $url = "/items?type=2"; Phake::when(Arsse::$db)->articleList->thenReturn(new Result([])); $this->req("GET", $url, json_encode($in)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->starred(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 5cab9964..b79ea2e8 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1749,19 +1749,19 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]]))); Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); - $c = (new Context)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"])->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"])->thenReturn(new Result($this->v($this->articles))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 1]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"])->thenReturn(new Result($this->v([['id' => 2]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 3]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 4]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 5]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"])->thenReturn(new Result($this->v([['id' => 6]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"])->thenReturn(new Result($this->v([['id' => 7]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 8]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 9]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"])->thenReturn(new Result($this->v([['id' => 10]]))); + $c = (new Context); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"], ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"], ["edited_date desc"])->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 2]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 3]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 4]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 5]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 6]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 7]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 8]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 9]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 10]]))); $out1 = [ $this->respErr("INCORRECT_USAGE"), $this->respGood([]), @@ -1793,9 +1793,9 @@ LONG_STRING; $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1001]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1002]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1003]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1001]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1002]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1003]]))); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1853,25 +1853,25 @@ LONG_STRING; 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), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(1)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything())->thenReturn($this->generateHeadlines(2)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(3)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(4)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(5)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything())->thenReturn($this->generateHeadlines(6)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything())->thenReturn($this->generateHeadlines(7)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(8)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(9)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything())->thenReturn($this->generateHeadlines(10)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything())->thenReturn($this->generateHeadlines(11)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything())->thenReturn($this->generateHeadlines(12)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything())->thenReturn($this->generateHeadlines(13)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); + $c = (new Context)->limit(200); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(2)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(3)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(4)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(5)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(6)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(7)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(8)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(9)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(10)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(11)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(12)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(13)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(14)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(15)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date"])->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1909,9 +1909,9 @@ LONG_STRING; $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in3); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1001)); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1002)); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything())->thenReturn($this->generateHeadlines(1003)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1001)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1002)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1003)); $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1990,7 +1990,7 @@ LONG_STRING; ]); $this->assertMessage($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")); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); $test = $this->req($in[6]); $exp = $this->respGood([ ['id' => 2112, 'is_cat' => false, 'first_id' => 0], @@ -2005,7 +2005,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with skip - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), $this->anything())->thenReturn($this->generateHeadlines(1867)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1867)); $test = $this->req($in[8]); $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 30b69b04..1644b594 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -98,6 +99,8 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } elseif ($exp instanceof XmlResponse) { + $this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text); } else { $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); } diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index a5945c46..faa225f1 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -522,16 +522,16 @@ }, { "name": "symfony/console", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "url": "https://api.github.com/repos/symfony/console/zipball/b592b26a24265a35172d8a2094d8b10f22b7cc39", + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39", "shasum": "" }, "require": { @@ -593,20 +593,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-06-05T13:25:51+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", - "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d257021c1ab28d48d24a16de79dfab445ce93398", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398", "shasum": "" }, "require": { @@ -663,7 +663,7 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-05-30T16:10:05+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -725,16 +725,16 @@ }, { "name": "symfony/filesystem", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", "shasum": "" }, "require": { @@ -771,20 +771,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-06-03T20:27:40+00:00" + "time": "2019-06-23T08:51:25+00:00" }, { "name": "symfony/finder", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "url": "https://api.github.com/repos/symfony/finder/zipball/33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", "shasum": "" }, "require": { @@ -820,20 +820,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-26T20:47:49+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332" + "reference": "40762ead607c8f792ee4516881369ffa553fee6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/914e0edcb7cd0c9f494bc023b1d47534f4542332", - "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/40762ead607c8f792ee4516881369ffa553fee6f", + "reference": "40762ead607c8f792ee4516881369ffa553fee6f", "shasum": "" }, "require": { @@ -874,7 +874,7 @@ "configuration", "options" ], - "time": "2019-05-10T05:38:46+00:00" + "time": "2019-06-13T11:01:17+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1167,7 +1167,7 @@ }, { "name": "symfony/process", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -1274,7 +1274,7 @@ }, { "name": "symfony/stopwatch", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index bc435909..e27b08c1 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -786,16 +786,16 @@ }, { "name": "phpunit/php-token-stream", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" + "reference": "c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c", + "reference": "c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c", "shasum": "" }, "require": { @@ -831,20 +831,20 @@ "keywords": [ "tokenizer" ], - "time": "2018-10-30T05:52:18+00:00" + "time": "2019-07-08T05:24:54+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.13", + "version": "7.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b9278591caa8630127f96c63b598712b699e671c" + "reference": "2834789aeb9ac182ad69bfdf9ae91856a59945ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b9278591caa8630127f96c63b598712b699e671c", - "reference": "b9278591caa8630127f96c63b598712b699e671c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2834789aeb9ac182ad69bfdf9ae91856a59945ff", + "reference": "2834789aeb9ac182ad69bfdf9ae91856a59945ff", "shasum": "" }, "require": { @@ -904,8 +904,8 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "role": "lead", + "email": "sebastian@phpunit.de" } ], "description": "The PHP Unit Testing framework.", @@ -915,7 +915,7 @@ "testing", "xunit" ], - "time": "2019-06-19T12:01:51+00:00" + "time": "2019-07-15T06:24:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index ad49752c..a544acc9 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -1092,16 +1092,16 @@ }, { "name": "symfony/console", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "url": "https://api.github.com/repos/symfony/console/zipball/b592b26a24265a35172d8a2094d8b10f22b7cc39", + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39", "shasum": "" }, "require": { @@ -1163,20 +1163,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-06-05T13:25:51+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", - "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d257021c1ab28d48d24a16de79dfab445ce93398", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398", "shasum": "" }, "require": { @@ -1233,7 +1233,7 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-05-30T16:10:05+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -1295,16 +1295,16 @@ }, { "name": "symfony/filesystem", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", "shasum": "" }, "require": { @@ -1341,20 +1341,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-06-03T20:27:40+00:00" + "time": "2019-06-23T08:51:25+00:00" }, { "name": "symfony/finder", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "url": "https://api.github.com/repos/symfony/finder/zipball/33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", "shasum": "" }, "require": { @@ -1390,7 +1390,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-26T20:47:49+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1569,16 +1569,16 @@ }, { "name": "symfony/process", - "version": "v3.4.28", + "version": "v3.4.29", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" + "reference": "d129c017e8602507688ef2c3007951a16c1a8407" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "url": "https://api.github.com/repos/symfony/process/zipball/d129c017e8602507688ef2c3007951a16c1a8407", + "reference": "d129c017e8602507688ef2c3007951a16c1a8407", "shasum": "" }, "require": { @@ -1614,7 +1614,7 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-05-22T12:54:11+00:00" + "time": "2019-05-30T15:47:52+00:00" }, { "name": "symfony/service-contracts", @@ -1676,7 +1676,7 @@ }, { "name": "symfony/yaml", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git",