Browse Source

Merge branch 'fever' into opml

microsub
J. King 5 years ago
parent
commit
2aa16f3405
  1. 2
      CHANGELOG
  2. 1
      README.md
  3. 6
      UPGRADING
  4. 2
      arsse.php
  5. 11
      composer.json
  6. 192
      composer.lock
  7. 5
      lib/Context/Context.php
  8. 69
      lib/Context/ExclusionContext.php
  9. 245
      lib/Database.php
  10. 277
      lib/REST/Fever/API.php
  11. 10
      lib/REST/NextCloudNews/V1_2.php
  12. 17
      lib/REST/TinyTinyRSS/API.php
  13. 4
      robo
  14. 64
      tests/cases/Database/SeriesArticle.php
  15. 25
      tests/cases/Misc/TestContext.php
  16. 341
      tests/cases/REST/Fever/TestAPI.php
  17. 34
      tests/cases/REST/NextCloudNews/TestV1_2.php
  18. 80
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  19. 3
      tests/lib/AbstractTest.php
  20. 54
      vendor-bin/csfixer/composer.lock
  21. 24
      vendor-bin/phpunit/composer.lock
  22. 52
      vendor-bin/robo/composer.lock

2
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)
==========================

1
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

6
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
=============================

2
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);
}

11
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": "*"

192
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",
@ -240,40 +292,96 @@
],
"time": "2016-08-06T14:39:51+00:00"
},
{
"name": "psr/http-server-handler",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-handler.git",
"reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
"reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
"shasum": ""
},
"require": {
"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": "1.8.6",
"version": "2.1.3",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-diactoros.git",
"reference": "20da13beba0dde8fb648be3cc19765732790f46e"
"reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e",
"reference": "20da13beba0dde8fb648be3cc19765732790f46e",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/279723778c40164bcf984a2df12ff2c6ec5e61c1",
"reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1",
"shasum": ""
},
"require": {
"php": "^5.6 || ^7.0",
"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": "*",

5
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);
}

69
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);

245
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,58 +1410,115 @@ class Database {
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
}
}
// handle complex context options
if ($context->annotated()) {
$comp = ($context->annotated) ? "<>" : "=";
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
}
if ($context->labelled()) {
// any label (true) or no label (false)
$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);
// 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 ($context->not->label()) {
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label);
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;
}
if ($context->labelName()) {
$q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName);
$q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values);
}
if ($context->not->labelName()) {
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName);
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 ($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 ($seen) {
$spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")";
$q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']);
}
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);
// handle complex context options
if ($context->annotated()) {
$comp = ($context->annotated) ? "<>" : "=";
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
}
if ($context->labelled()) {
// any label (true) or no label (false)
$op = $context->labelled ? ">" : "=";
$q->setWhere("coalesce(label_stats.assigned,0) $op 0");
}
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());
}

277
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);
}
}

10
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);

17
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);
}
}

4
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

64
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];
}
}
}

25
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)];

341
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<extended> */
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' => '<p>Article content 1</p>',
'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' => '<p>Article content 2</p>',
'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' => '<p>Article content 3</p>',
'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' => '<p>Article content 4</p>',
'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' => '<p>Article content 5</p>',
'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' => '<p>Article content 1</p>',
'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' => '<p>Article content 2</p>',
'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' => '<p>Article content 3</p>',
'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' => '<p>Article content 4</p>',
'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' => '<p>Article content 5</p>',
'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("<response><items><item><id>101</id><feed_id>8</feed_id><title>Article title 1</title><author></author><html>&lt;p&gt;Article content 1&lt;/p&gt;</html><url>http://example.com/1</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>946684800</created_on_time></item><item><id>102</id><feed_id>8</feed_id><title>Article title 2</title><author></author><html>&lt;p&gt;Article content 2&lt;/p&gt;</html><url>http://example.com/2</url><is_saved>0</is_saved><is_read>1</is_read><created_on_time>946771200</created_on_time></item><item><id>103</id><feed_id>9</feed_id><title>Article title 3</title><author></author><html>&lt;p&gt;Article content 3&lt;/p&gt;</html><url>http://example.com/3</url><is_saved>1</is_saved><is_read>0</is_read><created_on_time>946857600</created_on_time></item><item><id>104</id><feed_id>9</feed_id><title>Article title 4</title><author></author><html>&lt;p&gt;Article content 4&lt;/p&gt;</html><url>http://example.com/4</url><is_saved>1</is_saved><is_read>1</is_read><created_on_time>946944000</created_on_time></item><item><id>105</id><feed_id>10</feed_id><title>Article title 5</title><author></author><html>&lt;p&gt;Article content 5&lt;/p&gt;</html><url>http://example.com/5</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>947030400</created_on_time></item></items><total_items>1024</total_items></response>");
$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);
}
}

34
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"]);
}
}

80
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],

3
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);
}

54
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",

24
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",

52
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",

Loading…
Cancel
Save