1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 21:22:40 +00:00

Merge CORS branch

This commit is contained in:
J. King 2018-01-12 09:41:13 -05:00
commit 34b508171b
32 changed files with 1746 additions and 843 deletions

View file

@ -3,6 +3,12 @@ Version 0.3.0 (2018-??-??)
New features: New features:
- Support for SQLite3 via PDO - Support for SQLite3 via PDO
- Support for cross-origin resource sharing in all protocols
Bug fixes:
- Correctly handle %-encoded request URLs
- Overhaul protocol detection to fix various subtle bugs
- Overhaul HTTP response handling for more consistent results
Changes: Changes:
- Make date strings in TTRSS explicitly UTC - Make date strings in TTRSS explicitly UTC

View file

@ -86,10 +86,6 @@ The Arsse makes use of the [picoFeed] newsfeed parsing library to sanitize artic
As a general rule, The Arsse should yield the same output as the reference implementation for all valid inputs (otherwise you've found [a bug][newIssue]), but there are exception, either because the NextCloud News (hereafter "NCN") [protocol description][NCNv1] is at times ambiguous or incomplete, or because implementation details necessitate it differ; this section along with the General section above detail these differences. As a general rule, The Arsse should yield the same output as the reference implementation for all valid inputs (otherwise you've found [a bug][newIssue]), but there are exception, either because the NextCloud News (hereafter "NCN") [protocol description][NCNv1] is at times ambiguous or incomplete, or because implementation details necessitate it differ; this section along with the General section above detail these differences.
#### Missing features
- The Arsse does not implement [Cross-Origin Resource Sharing][CORS]
#### Differences #### Differences
- Article GUID hashes are not hashes like in NCN; they are integers rendered as strings - Article GUID hashes are not hashes like in NCN; they are integers rendered as strings

View file

@ -9,6 +9,14 @@ When upgrading between any two versions of The Arsse, the following are usually
- If installing from source, update dependencies with `composer install -o --no-dev` - If installing from source, update dependencies with `composer install -o --no-dev`
Upgrading from 0.2.1 to 0.3.0
=============================
- The following Composer dependencies have been added:
- zendframework/zend-diactoros
- psr/http-message
Upgrading from 0.2.0 to 0.2.1 Upgrading from 0.2.0 to 0.2.1
============================= =============================

View file

@ -24,5 +24,7 @@ if (\PHP_SAPI=="cli") {
Arsse::$conf->importFile(BASE."config.php"); Arsse::$conf->importFile(BASE."config.php");
} }
// handle Web requests // handle Web requests
(new REST)->dispatch()->output(); $emitter = new \Zend\Diactoros\Response\SapiEmitter();
$response = (new REST)->dispatch();
$emitter->emit($response);
} }

View file

@ -25,7 +25,8 @@
"fguillot/picofeed": ">=0.1.31", "fguillot/picofeed": ">=0.1.31",
"hosteurope/password-generator": "^1.0", "hosteurope/password-generator": "^1.0",
"docopt/docopt": "^1.0", "docopt/docopt": "^1.0",
"jkingweb/druuid": "^3.0" "jkingweb/druuid": "^3.0",
"zendframework/zend-diactoros": "^1.6"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "*" "bamarni/composer-bin-plugin": "*"

104
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8a3c7ff23f125a5fa3dac2e6a7244a90", "content-hash": "7d381fa958169b7079c1d3c5b911f3bd",
"packages": [ "packages": [
{ {
"name": "docopt/docopt", "name": "docopt/docopt",
@ -190,6 +190,108 @@
], ],
"time": "2017-02-09T14:17:01+00:00" "time": "2017-02-09T14:17:01+00:00"
}, },
{
"name": "psr/http-message",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
"shasum": ""
},
"require": {
"php": ">=5.3.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 interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"time": "2016-08-06T14:39:51+00:00"
},
{
"name": "zendframework/zend-diactoros",
"version": "1.6.1",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-diactoros.git",
"reference": "c8664b92a6d5bc229e48b0923486c097e45a7877"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/c8664b92a6d5bc229e48b0923486c097e45a7877",
"reference": "c8664b92a6d5bc229e48b0923486c097e45a7877",
"shasum": ""
},
"require": {
"php": "^5.6 || ^7.0",
"psr/http-message": "^1.0"
},
"provide": {
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"phpunit/phpunit": "^5.7.16 || ^6.0.8",
"zendframework/zend-coding-standard": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.6-dev",
"dev-develop": "1.7-dev"
}
},
"autoload": {
"psr-4": {
"Zend\\Diactoros\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"description": "PSR HTTP Message implementations",
"homepage": "https://github.com/zendframework/zend-diactoros",
"keywords": [
"http",
"psr",
"psr-7"
],
"time": "2017-10-12T15:24:51+00:00"
},
{ {
"name": "zendframework/zendxml", "name": "zendframework/zendxml",
"version": "1.0.2", "version": "1.0.2",

View file

@ -72,6 +72,13 @@ class Conf {
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $purgeArticlesUnread = "P21D"; public $purgeArticlesUnread = "P21D";
/** @var string Application name to present to clients during authentication */
public $httpRealm = "The Advanced RSS Environment";
/** @var string Space-separated list of origins from which to allow cross-origin resource sharing */
public $httpOriginsAllowed = "*";
/** @var string Space-separated list of origins from which to deny cross-origin resource sharing */
public $httpOriginsDenied = "";
/** Creates a new configuration object /** Creates a new configuration object
* @param string $import_file Optional file to read configuration data from * @param string $import_file Optional file to read configuration data from
* @see self::importFile() */ * @see self::importFile() */

View file

@ -6,8 +6,17 @@
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Arsse;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Response\EmptyResponse;
class REST { class REST {
protected $apis = [ const API_LIST = [
// NextCloud News version enumerator // NextCloud News version enumerator
'ncn' => [ 'ncn' => [
'match' => '/index.php/apps/news/api', 'match' => '/index.php/apps/news/api',
@ -21,7 +30,7 @@ class REST {
'class' => REST\NextCloudNews\V1_2::class, 'class' => REST\NextCloudNews\V1_2::class,
], ],
'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference 'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
'match' => '/tt-rss/api/', 'match' => '/tt-rss/api',
'strip' => '/tt-rss/api', 'strip' => '/tt-rss/api',
'class' => REST\TinyTinyRSS\API::class, 'class' => REST\TinyTinyRSS\API::class,
], ],
@ -34,50 +43,252 @@ class REST {
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
// Fever https://feedafever.com/api // Fever https://feedafever.com/api
// Feedbin v2 https://github.com/feedbin/feedbin-api // Feedbin v2 https://github.com/feedbin/feedbin-api
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
// Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown
// CommaFeed https://www.commafeed.com/api/ // CommaFeed https://www.commafeed.com/api/
// Unclear if clients exist:
// Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown
// NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
// Proprietary (centralized) entities: // Proprietary (centralized) entities:
// NewsBlur http://www.newsblur.com/api // NewsBlur http://www.newsblur.com/api
// Feedly https://developer.feedly.com/ // Feedly https://developer.feedly.com/
]; ];
const DEFAULT_PORTS = [
'http' => 80,
'https' => 443,
];
protected $apis = [];
public function __construct() { public function __construct(array $apis = null) {
$this->apis = $apis ?? self::API_LIST;
} }
public function dispatch(REST\Request $req = null): REST\Response { public function dispatch(ServerRequestInterface $req = null): ResponseInterface {
if ($req===null) { // create a request object if not provided
$req = new REST\Request(); $req = $req ?? ServerRequestFactory::fromGlobals();
} // find the API to handle
$api = $this->apiMatch($req->url, $this->apis); try {
$req->url = substr($req->url, strlen($this->apis[$api]['strip'])); list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis);
$req->refreshURL(); // authenticate the request pre-emptively
$class = $this->apis[$api]['class']; $req = $this->authenticateRequest($req);
$drv = new $class(); // modify the request to have an uppercase method and a stripped target
if ($req->head) { $req = $req->withMethod(strtoupper($req->getMethod()))->withRequestTarget($target);
$res = $drv->dispatch($req); // fetch the correct handler
$res->head = true; $drv = $this->getHandler($class);
return $res; // generate a response
} else { if ($req->getMethod()=="HEAD") {
return $drv->dispatch($req); // if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
$res = $drv->dispatch($req->withMethod("GET"));
} else {
$res = $drv->dispatch($req);
}
} catch (REST\Exception501 $e) {
$res = new EmptyResponse(501);
} }
// modify the response so that it has all the required metadata
return $this->normalizeResponse($res, $req);
} }
public function apiMatch(string $url, array $map): string { public function getHandler(string $className): REST\Handler {
// instantiate the API handler
return new $className();
}
public function apiMatch(string $url): array {
$map = $this->apis;
// sort the API list so the longest URL prefixes come first // sort the API list so the longest URL prefixes come first
uasort($map, function ($a, $b) { uasort($map, function ($a, $b) {
return (strlen($a['match']) <=> strlen($b['match'])) * -1; return (strlen($a['match']) <=> strlen($b['match'])) * -1;
}); });
// normalize the target URL
$url = REST\Target::normalize($url);
// find a match // find a match
foreach ($map as $id => $api) { foreach ($map as $id => $api) {
// first try a simple substring match
if (strpos($url, $api['match'])===0) { if (strpos($url, $api['match'])===0) {
return $id; // if it matches, perform a more rigorous match and then strip off any defined prefix
$pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
$target = substr($url, strlen($api['strip']));
} else {
// if the match fails we are not able to handle the request
throw new REST\Exception501();
}
// return the API name, stripped URL, and API class name
return [$id, $target, $api['class']];
} }
} }
// or throw an exception otherwise // or throw an exception otherwise
throw new REST\Exception501(); throw new REST\Exception501();
} }
public function authenticateRequest(ServerRequestInterface $req): ServerRequestInterface {
$user = "";
$password = "";
$env = $req->getServerParams();
if (isset($env['PHP_AUTH_USER'])) {
$user = $env['PHP_AUTH_USER'];
if (isset($env['PHP_AUTH_PW'])) {
$password = $env['PHP_AUTH_PW'];
}
} elseif (isset($env['REMOTE_USER'])) {
$user = $env['REMOTE_USER'];
}
if (strlen($user)) {
$valid = Arsse::$user->auth($user, $password);
}
if ($valid) {
$req = $req->withAttribute("authenticated", true);
$req = $req->withAttribute("authenticatedUser", $user);
}
return $req;
}
public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface {
$realm = $realm ?? Arsse::$conf->httpRealm ?? "Default";
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'"');
}
public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
// if the response code is 401, issue an HTTP authentication challenge
if ($res->getStatusCode()==401) {
$res = $this->challenge($res);
}
// set or clear the Content-Length header field
$body = $res->getBody();
$bodySize = $body->getSize();
if ($bodySize || $res->getStatusCode()==200) {
// if there is a message body or the response is 200, make sure Content-Length is included
$res = $res->withHeader("Content-Length", (string) $bodySize);
} else {
// for empty responses of other statuses, omit it
$res = $res->withoutHeader("Content-Length");
}
// if the response is to a HEAD request, the body should be omitted
if ($req && $req->getMethod()=="HEAD") {
$res = new EmptyResponse($res->getStatusCode(), $res->getHeaders());
}
// if an Allow header field is present, normalize it
if ($res->hasHeader("Allow")) {
$methods = preg_split("<\s*,\s*>", strtoupper($res->getHeaderLine("Allow")));
// if GET is allowed, HEAD should be allowed as well
if (in_array("GET", $methods) && !in_array("HEAD", $methods)) {
$methods[] = "HEAD";
}
// OPTIONS requests are always allowed by our handlers
if (!in_array("OPTIONS", $methods)) {
$methods[] = "OPTIONS";
}
$res = $res->withHeader("Allow", implode(", ", $methods));
}
// add CORS header fields if the request origin is specified and allowed
if ($req && $this->corsNegotiate($req)) {
$res = $this->corsApply($res, $req);
}
return $res;
}
public function corsApply(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
if ($req && $req->getMethod()=="OPTIONS") {
if ($res->hasHeader("Allow")) {
$res = $res->withHeader("Access-Control-Allow-Methods", $res->getHeaderLine("Allow"));
}
if ($req->hasHeader("Access-Control-Request-Headers")) {
$res = $res->withHeader("Access-Control-Allow-Headers", $req->getHeaderLine("Access-Control-Request-Headers"));
}
$res = $res->withHeader("Access-Control-Max-Age", (string) (60 *60 *24) ); // one day
}
$res = $res->withHeader("Access-Control-Allow-Origin", $req->getHeaderLine("Origin"));
$res = $res->withHeader("Access-Control-Allow-Credentials", "true");
return $res->withAddedHeader("Vary", "Origin");
}
public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool {
$allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? "");
$denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? "");
// continue if at least one origin is allowed
if ($allowed) {
// continue if the request has exactly one Origin header
$origin = $req->getHeader("Origin");
if (sizeof($origin)==1) {
// continue if the origin is syntactically valid
$origin = $this->corsNormalizeOrigin($origin[0]);
if ($origin) {
// the special "null" origin should not be matched by the wildcard origin
$null = ($origin=="null");
// pad all strings for simpler comparison
$allowed = " ".$allowed." ";
$denied = " ".$denied." ";
$origin = " ".$origin." ";
$any = " * ";
if (strpos($denied, $origin) !== false) {
// first check the denied list for the origin
return false;
} elseif (strpos($allowed, $origin) !== false) {
// next check the allowed list for the origin
return true;
} elseif (!$null && strpos($denied, $any) !== false) {
// next check the denied list for the wildcard origin
return false;
} elseif (!$null && strpos($allowed, $any) !== false) {
// finally check the allowed list for the wildcard origin
return true;
}
}
}
}
return false;
}
public function corsNormalizeOrigin(string $origin, array $ports = null): string {
$origin = trim($origin);
if ($origin=="null") {
// if the origin is the special value "null", use it
return "null";
}
if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>i", $origin, $match)) {
// if the origin sort-of matches the syntax in a general sense, continue
$scheme = $match[1];
$host = $match[2];
$port = $match[3];
// decode and normalize the scheme and port (the port may be blank)
$scheme = strtolower(rawurldecode($scheme));
$port = rawurldecode($port);
if (!preg_match("<^(?::[0-9]+)?$>", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>", $scheme)) {
// if the normalized port contains anything but numbers, or the scheme does not follow the generic URL syntax, the origin is invalid
return "";
}
if ($host[0]=="[") {
// if the host appears to be an IPv6 address, validate it
$host = rawurldecode(substr($host, 1, strlen($host) - 2));
if (!filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return "";
} else {
$host = "[".inet_ntop(inet_pton($host))."]";
}
} else {
// if the host is a domain name or IP address, split it along dots and just perform URL decoding
$host = explode(".", $host);
$host = array_map(function ($segment) {
return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment))));
}, $host);
$host = implode(".", $host);
}
// suppress default ports
if (strlen($port)) {
$port = (int) substr($port, 1);
$list = array_merge($ports ?? [], self::DEFAULT_PORTS);
if (isset($list[$scheme]) && $port==$list[$scheme]) {
$port = "";
} else {
$port = ":".$port;
}
}
// return the reconstructed result
return $scheme."://".$host.$port;
} else {
return "";
}
}
} }

View file

@ -8,10 +8,12 @@ namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\Misc\ValueInfo;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
abstract class AbstractHandler implements Handler { abstract class AbstractHandler implements Handler {
abstract public function __construct(); abstract public function __construct();
abstract public function dispatch(Request $req): Response; abstract public function dispatch(ServerRequestInterface $req): ResponseInterface;
protected function fieldMapNames(array $data, array $map): array { protected function fieldMapNames(array $data, array $map): array {
$out = []; $out = [];

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */ * See LICENSE and AUTHORS files for details */
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews; namespace JKingWeb\Arsse\REST;
class Exception404 extends \Exception { class Exception404 extends \Exception {
} }

View file

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */ * See LICENSE and AUTHORS files for details */
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews; namespace JKingWeb\Arsse\REST;
class Exception405 extends \Exception { class Exception405 extends \Exception {
} }

10
lib/REST/Exception501.php Normal file
View file

@ -0,0 +1,10 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
class Exception501 extends \Exception {
}

View file

@ -6,7 +6,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST; namespace JKingWeb\Arsse\REST;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
interface Handler { interface Handler {
public function __construct(); public function __construct();
public function dispatch(Request $req): Response; public function dispatch(ServerRequestInterface $req): ResponseInterface;
} }

View file

@ -15,7 +15,13 @@ use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\REST\Target;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
const REALM = "NextCloud News API v1-2"; const REALM = "NextCloud News API v1-2";
@ -41,100 +47,115 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY, 'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY,
]; ];
protected $paths = [ protected $paths = [
'folders' => ['GET' => "folderList", 'POST' => "folderAdd"], '/folders' => ['GET' => "folderList", 'POST' => "folderAdd"],
'folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], '/folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"],
'folders/1/read' => ['PUT' => "folderMarkRead"], '/folders/1/read' => ['PUT' => "folderMarkRead"],
'feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"], '/feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"],
'feeds/1' => ['DELETE' => "subscriptionRemove"], '/feeds/1' => ['DELETE' => "subscriptionRemove"],
'feeds/1/move' => ['PUT' => "subscriptionMove"], '/feeds/1/move' => ['PUT' => "subscriptionMove"],
'feeds/1/rename' => ['PUT' => "subscriptionRename"], '/feeds/1/rename' => ['PUT' => "subscriptionRename"],
'feeds/1/read' => ['PUT' => "subscriptionMarkRead"], '/feeds/1/read' => ['PUT' => "subscriptionMarkRead"],
'feeds/all' => ['GET' => "feedListStale"], '/feeds/all' => ['GET' => "feedListStale"],
'feeds/update' => ['GET' => "feedUpdate"], '/feeds/update' => ['GET' => "feedUpdate"],
'items' => ['GET' => "articleList"], '/items' => ['GET' => "articleList"],
'items/updated' => ['GET' => "articleList"], '/items/updated' => ['GET' => "articleList"],
'items/read' => ['PUT' => "articleMarkReadAll"], '/items/read' => ['PUT' => "articleMarkReadAll"],
'items/1/read' => ['PUT' => "articleMarkRead"], '/items/1/read' => ['PUT' => "articleMarkRead"],
'items/1/unread' => ['PUT' => "articleMarkRead"], '/items/1/unread' => ['PUT' => "articleMarkRead"],
'items/read/multiple' => ['PUT' => "articleMarkReadMulti"], '/items/read/multiple' => ['PUT' => "articleMarkReadMulti"],
'items/unread/multiple' => ['PUT' => "articleMarkReadMulti"], '/items/unread/multiple' => ['PUT' => "articleMarkReadMulti"],
'items/1/1/star' => ['PUT' => "articleMarkStarred"], '/items/1/1/star' => ['PUT' => "articleMarkStarred"],
'items/1/1/unstar' => ['PUT' => "articleMarkStarred"], '/items/1/1/unstar' => ['PUT' => "articleMarkStarred"],
'items/star/multiple' => ['PUT' => "articleMarkStarredMulti"], '/items/star/multiple' => ['PUT' => "articleMarkStarredMulti"],
'items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"], '/items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"],
'cleanup/before-update' => ['GET' => "cleanupBefore"], '/cleanup/before-update' => ['GET' => "cleanupBefore"],
'cleanup/after-update' => ['GET' => "cleanupAfter"], '/cleanup/after-update' => ['GET' => "cleanupAfter"],
'version' => ['GET' => "serverVersion"], '/version' => ['GET' => "serverVersion"],
'status' => ['GET' => "serverStatus"], '/status' => ['GET' => "serverStatus"],
'user' => ['GET' => "userStatus"], '/user' => ['GET' => "userStatus"],
]; ];
public function __construct() { public function __construct() {
} }
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(ServerRequestInterface $req): ResponseInterface {
// try to authenticate // try to authenticate
if (!Arsse::$user->authHTTP()) { if ($req->getAttribute("authenticated", false)) {
return new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.self::REALM.'"']); Arsse::$user->id = $req->getAttribute("authenticatedUser");
} else {
return new EmptyResponse(401);
} }
// explode and normalize the URL path
$target = new Target($req->getRequestTarget());
// handle HTTP OPTIONS requests // handle HTTP OPTIONS requests
if ($req->method=="OPTIONS") { if ($req->getMethod()=="OPTIONS") {
return $this->handleHTTPOptions($req->paths); return $this->handleHTTPOptions((string) $target);
} }
// normalize the input // normalize the input
if ($req->body) { $data = (string) $req->getBody();
$type = "";
if ($req->hasHeader("Content-Type")) {
$type = $req->getHeader("Content-Type");
$type = array_pop($type);
}
if ($data) {
// if the entity body is not JSON according to content type, return "415 Unsupported Media Type" // if the entity body is not JSON according to content type, return "415 Unsupported Media Type"
if (!preg_match("<^application/json\b|^$>", $req->type)) { if (!preg_match("<^application/json\b|^$>", $type)) {
return new Response(415, "", "", ['Accept: application/json']); return new EmptyResponse(415, ['Accept' => "application/json"]);
} }
$data = @json_decode($req->body, true); $data = @json_decode($data, true);
if (json_last_error() != \JSON_ERROR_NONE) { if (json_last_error() != \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request" // if the body could not be parsed as JSON, return "400 Bad Request"
return new Response(400); return new EmptyResponse(400);
} }
} else { } else {
$data = []; $data = [];
} }
// FIXME: Do query parameters take precedence in NextCloud? Is there a conflict error when values differ? // FIXME: Do query parameters take precedence in NextCloud? Is there a conflict error when values differ?
$data = $this->normalizeInput(array_merge($data, $req->query), $this->validInput, "unix"); $data = $this->normalizeInput(array_merge($data, $req->getQueryParams()), $this->validInput, "unix");
// check to make sure the requested function is implemented // check to make sure the requested function is implemented
try { try {
$func = $this->chooseCall($req->paths, $req->method); $func = $this->chooseCall((string) $target, $req->getMethod());
} catch (Exception404 $e) { } catch (Exception404 $e) {
return new Response(404); return new EmptyResponse(404);
} catch (Exception405 $e) { } catch (Exception405 $e) {
return new Response(405, "", "", ["Allow: ".$e->getMessage()]); return new EmptyResponse(405, ['Allow' => $e->getMessage()]);
} }
if (!method_exists($this, $func)) { if (!method_exists($this, $func)) {
return new Response(501); // @codeCoverageIgnore return new EmptyResponse(501); // @codeCoverageIgnore
} }
// dispatch // dispatch
try { try {
return $this->$func($req->paths, $data); return $this->$func($target->path, $data);
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
} catch (Exception $e) { } catch (Exception $e) {
// if there was a REST exception return 400 // if there was a REST exception return 400
return new Response(400); return new EmptyResponse(400);
} catch (AbstractException $e) { } catch (AbstractException $e) {
// if there was any other Arsse exception return 500 // if there was any other Arsse exception return 500
return new Response(500); return new EmptyResponse(500);
} }
// @codeCoverageIgnoreEnd // @codeCoverageIgnoreEnd
} }
protected function normalizePath(array $url): string { protected function normalizePathIds(string $url): string {
// any URL components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID) // first parse the URL and perform syntactic normalization
for ($a = 0; $a < sizeof($url); $a++) { $target = new Target($url);
if (ValueInfo::id($url[$a])) { // any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
$url[$a] = "1"; for ($a = 0; $a < sizeof($target->path); $a++) {
if (ValueInfo::id($target->path[$a])) {
$target->path[$a] = "1";
} }
} }
return implode("/", $url); // discard any fragment ID (there shouldn't be any) and query string (the query is available in the request itself)
$target->fragment = "";
$target->query = "";
return (string) $target;
} }
protected function chooseCall(array $url, string $method): string { protected function chooseCall(string $url, string $method): string {
// normalize the URL path // // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePath($url); $url = $this->normalizePathIds($url);
// normalize the HTTP method to uppercase // normalize the HTTP method to uppercase
$method = strtoupper($method); $method = strtoupper($method);
// we now evaluate the supplied URL against every supported path for the selected scope // we now evaluate the supplied URL against every supported path for the selected scope
@ -242,9 +263,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $article; return $article;
} }
protected function handleHTTPOptions(array $url): Response { protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePath($url); $url = $this->normalizePathIDs($url);
if (isset($this->paths[$url])) { if (isset($this->paths[$url])) {
// if the path is supported, respond with the allowed methods and other metadata // if the path is supported, respond with the allowed methods and other metadata
$allowed = array_keys($this->paths[$url]); $allowed = array_keys($this->paths[$url]);
@ -252,81 +273,81 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (in_array("GET", $allowed)) { if (in_array("GET", $allowed)) {
array_unshift($allowed, "HEAD"); array_unshift($allowed, "HEAD");
} }
return new Response(204, "", "", [ return new EmptyResponse(204, [
"Allow: ".implode(",", $allowed), 'Allow' => implode(",", $allowed),
"Accept: application/json", 'Accept' => "application/json",
]); ]);
} else { } else {
// if the path is not supported, return 404 // if the path is not supported, return 404
return new Response(404); return new EmptyResponse(404);
} }
} }
// list folders // list folders
protected function folderList(array $url, array $data): Response { protected function folderList(array $url, array $data): ResponseInterface {
$folders = []; $folders = [];
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $folder) { foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $folder) {
$folders[] = $this->folderTranslate($folder); $folders[] = $this->folderTranslate($folder);
} }
return new Response(200, ['folders' => $folders]); return new Response(['folders' => $folders]);
} }
// create a folder // create a folder
protected function folderAdd(array $url, array $data): Response { protected function folderAdd(array $url, array $data): ResponseInterface {
try { try {
$folder = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $data['name']]); $folder = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $data['name']]);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
switch ($e->getCode()) { switch ($e->getCode()) {
// folder already exists // folder already exists
case 10236: return new Response(409); case 10236: return new EmptyResponse(409);
// folder name not acceptable // folder name not acceptable
case 10231: case 10231:
case 10232: return new Response(422); case 10232: return new EmptyResponse(422);
// other errors related to input // other errors related to input
default: return new Response(400); // @codeCoverageIgnore default: return new EmptyResponse(400); // @codeCoverageIgnore
} }
} }
$folder = $this->folderTranslate(Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder)); $folder = $this->folderTranslate(Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder));
return new Response(200, ['folders' => [$folder]]); return new Response(['folders' => [$folder]]);
} }
// delete a folder // delete a folder
protected function folderRemove(array $url, array $data): Response { protected function folderRemove(array $url, array $data): ResponseInterface {
// perform the deletion // perform the deletion
try { try {
Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]); Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// folder does not exist // folder does not exist
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// rename a folder (also supports moving nesting folders, but this is not a feature of the API) // rename a folder (also supports moving nesting folders, but this is not a feature of the API)
protected function folderRename(array $url, array $data): Response { protected function folderRename(array $url, array $data): ResponseInterface {
try { try {
Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], ['name' => $data['name']]); Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], ['name' => $data['name']]);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
switch ($e->getCode()) { switch ($e->getCode()) {
// folder does not exist // folder does not exist
case 10239: return new Response(404); case 10239: return new EmptyResponse(404);
// folder already exists // folder already exists
case 10236: return new Response(409); case 10236: return new EmptyResponse(409);
// folder name not acceptable // folder name not acceptable
case 10231: case 10231:
case 10232: return new Response(422); case 10232: return new EmptyResponse(422);
// other errors related to input // other errors related to input
default: return new Response(400); // @codeCoverageIgnore default: return new EmptyResponse(400); // @codeCoverageIgnore
} }
} }
return new Response(204); return new EmptyResponse(204);
} }
// mark all articles associated with a folder as read // mark all articles associated with a folder as read
protected function folderMarkRead(array $url, array $data): Response { protected function folderMarkRead(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) { if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error // if the item ID is invalid (i.e. not a positive integer), this is an error
return new Response(422); return new EmptyResponse(422);
} }
// build the context // build the context
$c = new Context; $c = new Context;
@ -337,16 +358,16 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// folder does not exist // folder does not exist
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// return list of feeds which should be refreshed // return list of feeds which should be refreshed
protected function feedListStale(array $url, array $data): Response { protected function feedListStale(array $url, array $data): ResponseInterface {
// function requires admin rights per spec // function requires admin rights per spec
if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new EmptyResponse(403);
} }
// list stale feeds which should be checked for updates // list stale feeds which should be checked for updates
$feeds = Arsse::$db->feedListStale(); $feeds = Arsse::$db->feedListStale();
@ -355,42 +376,42 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string // since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string
$out[] = ['id' => (int) $feed, 'userId' => ""]; $out[] = ['id' => (int) $feed, 'userId' => ""];
} }
return new Response(200, ['feeds' => $out]); return new Response(['feeds' => $out]);
} }
// refresh a feed // refresh a feed
protected function feedUpdate(array $url, array $data): Response { protected function feedUpdate(array $url, array $data): ResponseInterface {
// function requires admin rights per spec // function requires admin rights per spec
if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new EmptyResponse(403);
} }
try { try {
Arsse::$db->feedUpdate($data['feedId']); Arsse::$db->feedUpdate($data['feedId']);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
switch ($e->getCode()) { switch ($e->getCode()) {
case 10239: // feed does not exist case 10239: // feed does not exist
return new Response(404); return new EmptyResponse(404);
case 10237: // feed ID invalid case 10237: // feed ID invalid
return new Response(422); return new EmptyResponse(422);
default: // other errors related to input default: // other errors related to input
return new Response(400); // @codeCoverageIgnore return new EmptyResponse(400); // @codeCoverageIgnore
} }
} }
return new Response(204); return new EmptyResponse(204);
} }
// add a new feed // add a new feed
protected function subscriptionAdd(array $url, array $data): Response { protected function subscriptionAdd(array $url, array $data): ResponseInterface {
// try to add the feed // try to add the feed
$tr = Arsse::$db->begin(); $tr = Arsse::$db->begin();
try { try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, (string) $data['url']); $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, (string) $data['url']);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// feed already exists // feed already exists
return new Response(409); return new EmptyResponse(409);
} catch (FeedException $e) { } catch (FeedException $e) {
// feed could not be retrieved // feed could not be retrieved
return new Response(422); return new EmptyResponse(422);
} }
// if a folder was specified, move the feed to the correct folder; silently ignore errors // if a folder was specified, move the feed to the correct folder; silently ignore errors
if ($data['folderId']) { if ($data['folderId']) {
@ -408,11 +429,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($newest) { if ($newest) {
$out['newestItemId'] = $newest; $out['newestItemId'] = $newest;
} }
return new Response(200, $out); return new Response($out);
} }
// return list of feeds for the logged-in user // return list of feeds for the logged-in user
protected function subscriptionList(array $url, array $data): Response { protected function subscriptionList(array $url, array $data): ResponseInterface {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id); $subs = Arsse::$db->subscriptionList(Arsse::$user->id);
$out = []; $out = [];
foreach ($subs as $sub) { foreach ($subs as $sub) {
@ -424,43 +445,43 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($newest) { if ($newest) {
$out['newestItemId'] = $newest; $out['newestItemId'] = $newest;
} }
return new Response(200, $out); return new Response($out);
} }
// delete a feed // delete a feed
protected function subscriptionRemove(array $url, array $data): Response { protected function subscriptionRemove(array $url, array $data): ResponseInterface {
try { try {
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]); Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// feed does not exist // feed does not exist
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// rename a feed // rename a feed
protected function subscriptionRename(array $url, array $data): Response { protected function subscriptionRename(array $url, array $data): ResponseInterface {
try { try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['title' => (string) $data['feedTitle']]); Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['title' => (string) $data['feedTitle']]);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
switch ($e->getCode()) { switch ($e->getCode()) {
// subscription does not exist // subscription does not exist
case 10239: return new Response(404); case 10239: return new EmptyResponse(404);
// name is invalid // name is invalid
case 10231: case 10231:
case 10232: return new Response(422); case 10232: return new EmptyResponse(422);
// other errors related to input // other errors related to input
default: return new Response(400); // @codeCoverageIgnore default: return new EmptyResponse(400); // @codeCoverageIgnore
} }
} }
return new Response(204); return new EmptyResponse(204);
} }
// move a feed to a folder // move a feed to a folder
protected function subscriptionMove(array $url, array $data): Response { protected function subscriptionMove(array $url, array $data): ResponseInterface {
// if no folder is specified this is an error // if no folder is specified this is an error
if (!isset($data['folderId'])) { if (!isset($data['folderId'])) {
return new Response(422); return new EmptyResponse(422);
} }
// perform the move // perform the move
try { try {
@ -468,22 +489,22 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
switch ($e->getCode()) { switch ($e->getCode()) {
case 10239: // subscription does not exist case 10239: // subscription does not exist
return new Response(404); return new EmptyResponse(404);
case 10235: // folder does not exist case 10235: // folder does not exist
case 10237: // folder ID is invalid case 10237: // folder ID is invalid
return new Response(422); return new EmptyResponse(422);
default: // other errors related to input default: // other errors related to input
return new Response(400); // @codeCoverageIgnore return new EmptyResponse(400); // @codeCoverageIgnore
} }
} }
return new Response(204); return new EmptyResponse(204);
} }
// mark all articles associated with a subscription as read // mark all articles associated with a subscription as read
protected function subscriptionMarkRead(array $url, array $data): Response { protected function subscriptionMarkRead(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) { if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error // if the item ID is invalid (i.e. not a positive integer), this is an error
return new Response(422); return new EmptyResponse(422);
} }
// build the context // build the context
$c = new Context; $c = new Context;
@ -494,13 +515,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// subscription does not exist // subscription does not exist
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// list articles and their properties // list articles and their properties
protected function articleList(array $url, array $data): Response { protected function articleList(array $url, array $data): ResponseInterface {
// set the context options supplied by the client // set the context options supplied by the client
$c = new Context; $c = new Context;
// set the batch size // set the batch size
@ -553,32 +574,32 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL); $items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID of subscription or folder is not valid // ID of subscription or folder is not valid
return new Response(422); return new EmptyResponse(422);
} }
$out = []; $out = [];
foreach ($items as $item) { foreach ($items as $item) {
$out[] = $this->articleTranslate($item); $out[] = $this->articleTranslate($item);
} }
$out = ['items' => $out]; $out = ['items' => $out];
return new Response(200, $out); return new Response($out);
} }
// mark all articles as read // mark all articles as read
protected function articleMarkReadAll(array $url, array $data): Response { protected function articleMarkReadAll(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) { if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error // if the item ID is invalid (i.e. not a positive integer), this is an error
return new Response(422); return new EmptyResponse(422);
} }
// build the context // build the context
$c = new Context; $c = new Context;
$c->latestEdition((int) $data['newestItemId']); $c->latestEdition((int) $data['newestItemId']);
// perform the operation // perform the operation
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
return new Response(204); return new EmptyResponse(204);
} }
// mark a single article as read // mark a single article as read
protected function articleMarkRead(array $url, array $data): Response { protected function articleMarkRead(array $url, array $data): ResponseInterface {
// initialize the matching context // initialize the matching context
$c = new Context; $c = new Context;
$c->edition((int) $url[1]); $c->edition((int) $url[1]);
@ -588,13 +609,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID is not valid // ID is not valid
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// mark a single article as read // mark a single article as read
protected function articleMarkStarred(array $url, array $data): Response { protected function articleMarkStarred(array $url, array $data): ResponseInterface {
// initialize the matching context // initialize the matching context
$c = new Context; $c = new Context;
$c->article((int) $url[2]); $c->article((int) $url[2]);
@ -604,13 +625,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID is not valid // ID is not valid
return new Response(404); return new EmptyResponse(404);
} }
return new Response(204); return new EmptyResponse(204);
} }
// mark an array of articles as read // mark an array of articles as read
protected function articleMarkReadMulti(array $url, array $data): Response { protected function articleMarkReadMulti(array $url, array $data): ResponseInterface {
// determine whether to mark read or unread // determine whether to mark read or unread
$set = ($url[1]=="read"); $set = ($url[1]=="read");
// initialize the matching context // initialize the matching context
@ -620,11 +641,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
} }
return new Response(204); return new EmptyResponse(204);
} }
// mark an array of articles as starred // mark an array of articles as starred
protected function articleMarkStarredMulti(array $url, array $data): Response { protected function articleMarkStarredMulti(array $url, array $data): ResponseInterface {
// determine whether to mark starred or unstarred // determine whether to mark starred or unstarred
$set = ($url[1]=="star"); $set = ($url[1]=="star");
// initialize the matching context // initialize the matching context
@ -634,10 +655,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
} }
return new Response(204); return new EmptyResponse(204);
} }
protected function userStatus(array $url, array $data): Response { protected function userStatus(array $url, array $data): ResponseInterface {
$data = Arsse::$user->propertiesGet(Arsse::$user->id, true); $data = Arsse::$user->propertiesGet(Arsse::$user->id, true);
// construct the avatar structure, if an image is available // construct the avatar structure, if an image is available
if (isset($data['avatar'])) { if (isset($data['avatar'])) {
@ -655,37 +676,37 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'lastLoginTimestamp' => time(), 'lastLoginTimestamp' => time(),
'avatar' => $avatar, 'avatar' => $avatar,
]; ];
return new Response(200, $out); return new Response($out);
} }
protected function cleanupBefore(array $url, array $data): Response { protected function cleanupBefore(array $url, array $data): ResponseInterface {
// function requires admin rights per spec // function requires admin rights per spec
if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new EmptyResponse(403);
} }
Service::cleanupPre(); Service::cleanupPre();
return new Response(204); return new EmptyResponse(204);
} }
protected function cleanupAfter(array $url, array $data): Response { protected function cleanupAfter(array $url, array $data): ResponseInterface {
// function requires admin rights per spec // function requires admin rights per spec
if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new EmptyResponse(403);
} }
Service::cleanupPost(); Service::cleanupPost();
return new Response(204); return new EmptyResponse(204);
} }
// return the server version // return the server version
protected function serverVersion(array $url, array $data): Response { protected function serverVersion(array $url, array $data): ResponseInterface {
return new Response(200, [ return new Response([
'version' => self::VERSION, 'version' => self::VERSION,
'arsse_version' => Arsse::VERSION, 'arsse_version' => Arsse::VERSION,
]); ]);
} }
protected function serverStatus(array $url, array $data): Response { protected function serverStatus(array $url, array $data): ResponseInterface {
return new Response(200, [ return new Response([
'version' => self::VERSION, 'version' => self::VERSION,
'arsse_version' => Arsse::VERSION, 'arsse_version' => Arsse::VERSION,
'warnings' => [ 'warnings' => [

View file

@ -6,30 +6,35 @@
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews; namespace JKingWeb\Arsse\REST\NextCloudNews;
use JKingWeb\Arsse\REST\Response; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
class Versions implements \JKingWeb\Arsse\REST\Handler { class Versions implements \JKingWeb\Arsse\REST\Handler {
public function __construct() { public function __construct() {
} }
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(ServerRequestInterface $req): ResponseInterface {
if (!preg_match("<^/?$>", $req->path)) { if (!preg_match("<^/?$>", $req->getRequestTarget())) {
// if the request path is an empty string or just a slash, the client is probably trying a version we don't support // if the request path is more than an empty string or a slash, the client is probably trying a version we don't support
return new Response(404); return new EmptyResponse(404);
} elseif ($req->method=="OPTIONS") { }
// if the request method is OPTIONS, respond accordingly switch ($req->getMethod()) {
return new Response(204, "", "", ["Allow: HEAD,GET"]); case "OPTIONS":
} elseif ($req->method != "GET") { // if the request method is OPTIONS, respond accordingly
// if a method other than GET was used, this is an error return new EmptyResponse(204, ['Allow' => "HEAD,GET"]);
return new Response(405, "", "", ["Allow: HEAD,GET"]); case "GET":
} else { // otherwise return the supported versions
// otherwise return the supported versions $out = [
$out = [ 'apiLevels' => [
'apiLevels' => [ 'v1-2',
'v1-2', ]
] ];
]; return new Response($out);
return new Response(200, $out); default:
// if any other method was used, this is an error
return new EmptyResponse(405, ['Allow' => "HEAD,GET"]);
} }
} }
} }

View file

@ -1,89 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
class Request {
public $method = "GET";
public $head = false;
public $url = "";
public $path ="";
public $paths = [];
public $query = "";
public $type ="";
public $body = "";
public function __construct(string $method = null, string $url = null, string $body = null, string $contentType = null) {
$method = $method ?? $_SERVER['REQUEST_METHOD'];
$url = $url ?? $_SERVER['REQUEST_URI'];
$body = $body ?? file_get_contents("php://input");
if (is_null($contentType)) {
if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
$contentType = $_SERVER['HTTP_CONTENT_TYPE'];
} else {
$contentType = "";
}
}
$this->method = strtoupper($method);
$this->url = $url;
$this->body = $body;
$this->type = $contentType;
if ($this->method=="HEAD") {
$this->head = true;
$this->method = "GET";
}
$this->refreshURL();
}
public function refreshURL() {
$url = $this->parseURL($this->url);
$this->path = $url['path'];
$this->paths = $url['paths'];
$this->query = $url['query'];
}
protected function parseURL(string $url): array {
// split the query string from the path
$parts = explode("?", $url);
$out = ['path' => $parts[0], 'paths' => [''], 'query' => []];
// if there is a query string, parse it
if (isset($parts[1])) {
// split along & to get key-value pairs
$query = explode("&", $parts[1]);
for ($a = 0; $a < sizeof($query); $a++) {
// split each pair, into no more than two parts
$data = explode("=", $query[$a], 2);
// decode the key
$key = rawurldecode($data[0]);
// decode the value if there is one
$value = "";
if (isset($data[1])) {
$value = rawurldecode($data[1]);
}
// add the pair to the query output, overwriting earlier values for the same key, is present
$out['query'][$key] = $value;
}
}
// also include the path as a set of decoded elements
// if the path is an empty string or just / nothing needs be done
if (!in_array($out['path'], ["/",""])) {
$paths = explode("/", $out['path']);
// remove the first and last empty elements, if present (they are artefacts of the splitting; others should remain)
if (!strlen($paths[0])) {
array_shift($paths);
}
if (!strlen($paths[sizeof($paths)-1])) {
array_pop($paths);
}
// %-decode each path element
$paths = array_map(function ($v) {
return rawurldecode($v);
}, $paths);
$out['paths'] = $paths;
}
return $out;
}
}

View file

@ -1,65 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Arsse;
class Response {
const T_JSON = "application/json";
const T_XML = "application/xml";
const T_TEXT = "text/plain";
public $head = false;
public $code;
public $payload;
public $type;
public $fields;
public function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) {
$this->code = $code;
$this->payload = $payload;
$this->type = $type;
$this->fields = $extraFields;
}
public function output() {
if (!headers_sent()) {
foreach ($this->fields as $field) {
header($field);
}
$body = "";
if (!is_null($this->payload)) {
switch ($this->type) {
case self::T_JSON:
$body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT);
break;
default:
$body = (string) $this->payload;
break;
}
}
if (strlen($body)) {
header("Content-Type: ".$this->type);
header("Content-Length: ".strlen($body));
} elseif ($this->code==200) {
$this->code = 204;
}
try {
$statusText = Arsse::$lang->msg("HTTP.Status.".$this->code);
} catch (\JKingWeb\Arsse\Lang\Exception $e) {
$statusText = "";
}
header("Status: ".$this->code." ".$statusText);
if (!$this->head) {
echo $body;
}
} else {
throw new REST\Exception("headersSent");
}
}
}

131
lib/REST/Target.php Normal file
View file

@ -0,0 +1,131 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Misc\ValueInfo;
class Target {
public $relative = false;
public $index = false;
public $path = [];
public $query = "";
public $fragment = "";
public function __construct(string $target) {
$target = $this->parseFragment($target);
$target = $this->parseQuery($target);
$this->path = $this->parsePath($target);
}
public function __toString(): string {
$out = "";
$path = [];
foreach ($this->path as $segment) {
if (is_null($segment)) {
if (!$path) {
$path[] = "..";
} else {
continue;
}
} elseif ($segment==".") {
$path[] = "%2E";
} elseif ($segment=="..") {
$path[] = "%2E%2E";
} else {
$path[] = rawurlencode(ValueInfo::normalize($segment, ValueInfo::T_STRING));
}
}
$path = implode("/", $path);
if (!$this->relative) {
$out .= "/";
}
$out .= $path;
if ($this->index && strlen($path)) {
$out .= "/";
}
if (strlen($this->query)) {
$out .= "?".$this->query;
}
if (strlen($this->fragment)) {
$out .= "#".rawurlencode($this->fragment);
}
return $out;
}
public static function normalize(string $target): string {
return (string) new self($target);
}
protected function parseFragment(string $target): string {
// store and strip off any fragment identifier and return the target without a fragment
$pos = strpos($target,"#");
if ($pos !== false) {
$this->fragment = rawurldecode(substr($target, $pos + 1));
$target = substr($target, 0, $pos);
}
return $target;
}
protected function parseQuery(string $target): string {
// store and strip off any query string and return the target without a query
// note that the function assumes any fragment identifier has already been stripped off
// unlike the other parts the query string is currently neither parsed nor normalized
$pos = strpos($target,"?");
if ($pos !== false) {
$this->query = substr($target, $pos + 1);
$target = substr($target, 0, $pos);
}
return $target;
}
protected function parsePath(string $target): array {
// note that the function assumes any fragment identifier or query has already been stripped off
// syntax-based normalization is applied to the path segments (see RFC 3986 sec. 6.2.2)
// duplicate slashes are NOT collapsed
if (substr($target, 0, 1)=="/") {
// if the path starts with a slash, strip it off
$target = substr($target, 1);
} else {
// otherwise this is a relative target
$this->relative = true;
}
if (!strlen($target)) {
// if the target is an empty string, this is an index target
$this->index = true;
} elseif (substr($target, -1, 1)=="/") {
// if the path ends in a slash, this is an index target and the slash should be stripped off
$this->index = true;
$target = substr($target, 0, strlen($target) -1);
}
// after stripping, explode the path parts
if (strlen($target)) {
$target = explode("/", $target);
$out = [];
// resolve relative path segments and decode each retained segment
foreach($target as $index => $segment) {
if ($segment==".") {
// self-referential segments can be ignored
continue;
} elseif ($segment=="..") {
if ($index==0) {
// if the first path segment refers to its parent (which we don't know about) we cannot output a correct path, so we do the best we can
$out[] = null;
} else {
// for any other segments after the first we pop off the last stored segment
array_pop($out);
}
} else {
// any other segment is decoded and retained
$out[] = rawurldecode($segment);
}
}
return $out;
} else {
return [];
}
}
}

View file

@ -19,7 +19,10 @@ use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ResultEmpty; use JKingWeb\Arsse\Db\ResultEmpty;
use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\REST\Response; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler { class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const LEVEL = 14; // emulated API level const LEVEL = 14; // emulated API level
@ -88,23 +91,24 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() { public function __construct() {
} }
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(ServerRequestInterface $req): ResponseInterface {
if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->path)) { if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->getRequestTarget())) {
// reject paths other than the index // reject paths other than the index
return new Response(404); return new EmptyResponse(404);
} }
if ($req->method=="OPTIONS") { if ($req->getMethod()=="OPTIONS") {
// respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method // respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method
return new Response(204, "", "", [ return new EmptyResponse(204, [
"Allow: POST", 'Allow' => "POST",
"Accept: application/json, text/json", 'Accept' => "application/json, text/json",
]); ]);
} }
if ($req->body) { $data = (string) $req->getBody();
if ($data) {
// only JSON entities are allowed, but Content-Type is ignored, as is request method // only JSON entities are allowed, but Content-Type is ignored, as is request method
$data = @json_decode($req->body, true); $data = @json_decode($data, true);
if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) {
return new Response(200, self::FATAL_ERR); return new Response(self::FATAL_ERR);
} }
try { try {
// normalize input // normalize input
@ -123,23 +127,23 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TT-RSS operations are case-insensitive by dint of PHP method names being case-insensitive; this will only trigger if the method really doesn't exist // TT-RSS operations are case-insensitive by dint of PHP method names being case-insensitive; this will only trigger if the method really doesn't exist
throw new Exception("UNKNOWN_METHOD", ['method' => $data['op']]); throw new Exception("UNKNOWN_METHOD", ['method' => $data['op']]);
} }
return new Response(200, [ return new Response([
'seq' => $data['seq'], 'seq' => $data['seq'],
'status' => 0, 'status' => 0,
'content' => $this->$method($data), 'content' => $this->$method($data),
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
return new Response(200, [ return new Response([
'seq' => $data['seq'], 'seq' => $data['seq'],
'status' => 1, 'status' => 1,
'content' => $e->getData(), 'content' => $e->getData(),
]); ]);
} catch (AbstractException $e) { } catch (AbstractException $e) {
return new Response(500); return new EmptyResponse(500);
} }
} else { } else {
// absence of a request body indicates an error // absence of a request body indicates an error
return new Response(200, self::FATAL_ERR); return new Response(self::FATAL_ERR);
} }
} }

View file

@ -7,17 +7,19 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS; namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\REST\Response; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\EmptyResponse as Response;
class Icon extends \JKingWeb\Arsse\REST\AbstractHandler { class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() { public function __construct() {
} }
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(ServerRequestInterface $req): ResponseInterface {
if ($req->method != "GET") { if ($req->getMethod() != "GET") {
// only GET requests are allowed // only GET requests are allowed
return new Response(405, "", "", ["Allow: GET"]); return new Response(405, ['Allow' => "GET"]);
} elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) { } elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) {
return new Response(404); return new Response(404);
} }
$url = Arsse::$db->subscriptionFavicon((int) $match[1]); $url = Arsse::$db->subscriptionFavicon((int) $match[1]);
@ -26,7 +28,7 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) { if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) {
$url = substr($url, 0, $pos); $url = substr($url, 0, $pos);
} }
return new Response(301, "", "", ["Location: $url"]); return new Response(301, ['Location' => $url]);
} else { } else {
return new Response(404); return new Response(404);
} }

View file

@ -9,4 +9,5 @@ namespace JKingWeb\Arsse;
const NS_BASE = __NAMESPACE__."\\"; const NS_BASE = __NAMESPACE__."\\";
define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR);
ini_set("memory_limit", "-1"); ini_set("memory_limit", "-1");
error_reporting(\E_ALL);
require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";

View file

@ -11,14 +11,16 @@ use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\User; use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response;
use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\NextCloudNews\V1_2; use JKingWeb\Arsse\REST\NextCloudNews\V1_2;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
use Phake; use Phake;
/** @covers \JKingWeb\Arsse\REST\NextCloudNews\V1_2<extended> */ /** @covers \JKingWeb\Arsse\REST\NextCloudNews\V1_2<extended> */
@ -299,12 +301,49 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
], ],
]; ];
protected function req(string $method, string $target, string $data = "", array $headers = []): ResponseInterface {
$url = "/index.php/apps/news/api/v1-2".$target;
$server = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $url,
'PHP_AUTH_USER' => "john.doe@example.com",
'PHP_AUTH_PW' => "secret",
'REMOTE_USER' => "john.doe@example.com",
];
if (strlen($data)) {
$server['HTTP_CONTENT_TYPE'] = "application/json";
}
$req = new ServerRequest($server, [], $url, $method, "php://memory");
if (Arsse::$user->auth()) {
$req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", "john.doe@example.com");
}
foreach($headers as $key => $value) {
if (!is_null($value)) {
$req = $req->withHeader($key, $value);
} else {
$req = $req->withoutHeader($key);
}
}
if (strlen($data)) {
$body = $req->getBody();
$body->write($data);
$req = $req->withBody($body);
}
$q = $req->getUri()->getQuery();
if (strlen($q)) {
parse_str($q, $q);
$req = $req->withQueryParams($q);
}
$req = $req->withRequestTarget($target);
return $this->h->dispatch($req);
}
public function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
Arsse::$conf = new Conf(); Arsse::$conf = new Conf();
// create a mock user manager // create a mock user manager
Arsse::$user = Phake::mock(User::class); Arsse::$user = Phake::mock(User::class);
Phake::when(Arsse::$user)->authHTTP->thenReturn(true); Phake::when(Arsse::$user)->auth->thenReturn(true);
Phake::when(Arsse::$user)->rightsGet->thenReturn(100); Phake::when(Arsse::$user)->rightsGet->thenReturn(100);
Arsse::$user->id = "john.doe@example.com"; Arsse::$user->id = "john.doe@example.com";
// create a mock database interface // create a mock database interface
@ -321,15 +360,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
return $value; return $value;
} }
protected function assertResponse(Response $exp, Response $act, string $text = null) {
$this->assertEquals($exp, $act, $text);
$this->assertSame($exp->payload, $act->payload, $text);
}
public function testSendAuthenticationChallenge() { public function testSendAuthenticationChallenge() {
Phake::when(Arsse::$user)->authHTTP->thenReturn(false); Phake::when(Arsse::$user)->auth->thenReturn(false);
$exp = new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.V1_2::REALM.'"']); $exp = new EmptyResponse(401);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/"))); $this->assertMessage($exp, $this->req("GET", "/"));
} }
public function testRespondToInvalidPaths() { public function testRespondToInvalidPaths() {
@ -365,44 +399,45 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
], ],
]; ];
foreach ($errs[404] as $req) { foreach ($errs[404] as $req) {
$exp = new Response(404); $exp = new EmptyResponse(404);
list($method, $path) = $req; list($method, $path) = $req;
$this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 404."); $this->assertMessage($exp, $this->req($method, $path), "$method call to $path did not return 404.");
} }
foreach ($errs[405] as $allow => $cases) { foreach ($errs[405] as $allow => $cases) {
$exp = new Response(405, "", "", ['Allow: '.$allow]); $exp = new EmptyResponse(405, ['Allow' => $allow]);
foreach ($cases as $req) { foreach ($cases as $req) {
list($method, $path) = $req; list($method, $path) = $req;
$this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 405."); $this->assertMessage($exp, $this->req($method, $path), "$method call to $path did not return 405.");
} }
} }
} }
public function testRespondToInvalidInputTypes() { public function testRespondToInvalidInputTypes() {
$exp = new Response(415, "", "", ['Accept: application/json']); $exp = new EmptyResponse(415, ['Accept' => "application/json"]);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/xml'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>', ['Content-Type' => "application/xml"]));
$exp = new Response(400); $exp = new EmptyResponse(400);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>'));
$this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>', ['Content-Type' => null]));
} }
public function testRespondToOptionsRequests() { public function testRespondToOptionsRequests() {
$exp = new Response(204, "", "", [ $exp = new EmptyResponse(204, [
"Allow: HEAD,GET,POST", 'Allow' => "HEAD,GET,POST",
"Accept: application/json", 'Accept' => "application/json",
]); ]);
$this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds"))); $this->assertMessage($exp, $this->req("OPTIONS", "/feeds"));
$exp = new Response(204, "", "", [ $exp = new EmptyResponse(204, [
"Allow: DELETE", 'Allow' => "DELETE",
"Accept: application/json", 'Accept' => "application/json",
]); ]);
$this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds/2112"))); $this->assertMessage($exp, $this->req("OPTIONS", "/feeds/2112"));
$exp = new Response(204, "", "", [ $exp = new EmptyResponse(204, [
"Allow: HEAD,GET", 'Allow' => "HEAD,GET",
"Accept: application/json", 'Accept' => "application/json",
]); ]);
$this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/user"))); $this->assertMessage($exp, $this->req("OPTIONS", "/user"));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/invalid/path"))); $this->assertMessage($exp, $this->req("OPTIONS", "/invalid/path"));
} }
public function testListFolders() { public function testListFolders() {
@ -415,10 +450,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 12, 'name' => "Hardware"], ['id' => 12, 'name' => "Hardware"],
]; ];
Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($this->v($list))); Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($this->v($list)));
$exp = new Response(200, ['folders' => []]); $exp = new Response(['folders' => []]);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); $this->assertMessage($exp, $this->req("GET", "/folders"));
$exp = new Response(200, ['folders' => $out]); $exp = new Response(['folders' => $out]);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); $this->assertMessage($exp, $this->req("GET", "/folders"));
} }
public function testAddAFolder() { public function testAddAFolder() {
@ -445,34 +480,34 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing"));
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace"));
// correctly add two folders, using different means // correctly add two folders, using different means
$exp = new Response(200, ['folders' => [$out[0]]]); $exp = new Response(['folders' => [$out[0]]]);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/folders", json_encode($in[0])));
$exp = new Response(200, ['folders' => [$out[1]]]); $exp = new Response(['folders' => [$out[1]]]);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Hardware"))); $this->assertMessage($exp, $this->req("POST", "/folders?name=Hardware"));
Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0]); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0]);
Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1]); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1]);
Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1);
Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2);
// test bad folder names // test bad folder names
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders"))); $this->assertMessage($exp, $this->req("POST", "/folders"));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":""}'));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":" "}'));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":{}}', 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":{}}'));
// try adding the same two folders again // try adding the same two folders again
$exp = new Response(409); $exp = new EmptyResponse(409);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); $this->assertMessage($exp, $this->req("POST", "/folders?name=Software"));
$exp = new Response(409); $exp = new EmptyResponse(409);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/folders", json_encode($in[1])));
} }
public function testRemoveAFolder() { public function testRemoveAFolder() {
Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); $this->assertMessage($exp, $this->req("DELETE", "/folders/1"));
// fail on the second invocation because it no longer exists // fail on the second invocation because it no longer exists
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); $this->assertMessage($exp, $this->req("DELETE", "/folders/1"));
Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1);
} }
@ -490,26 +525,26 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[3])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[3])->thenThrow(new ExceptionInput("whitespace"));
Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[4])->thenReturn(true); // this should be stopped by the handler before the request gets to the database Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[4])->thenReturn(true); // this should be stopped by the handler before the request gets to the database
Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 3, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // folder ID 3 does not exist Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 3, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // folder ID 3 does not exist
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[0])));
$exp = new Response(409); $exp = new EmptyResponse(409);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/2", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/2", json_encode($in[1])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[2])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[3])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[4]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[4])));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/3", json_encode($in[0])));
} }
public function testRetrieveServerVersion() { public function testRetrieveServerVersion() {
$exp = new Response(200, [ $exp = new Response([
'version' => V1_2::VERSION, 'version' => V1_2::VERSION,
'arsse_version' => Arsse::VERSION, 'arsse_version' => Arsse::VERSION,
]); ]);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/version"))); $this->assertMessage($exp, $this->req("GET", "/version"));
} }
public function testListSubscriptions() { public function testListSubscriptions() {
@ -525,10 +560,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->v($this->feeds['db']))); Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->v($this->feeds['db'])));
Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn($this->v(['total' => 0]))->thenReturn($this->v(['total' => 5])); Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn($this->v(['total' => 0]))->thenReturn($this->v(['total' => 5]));
Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915);
$exp = new Response(200, $exp1); $exp = new Response($exp1);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); $this->assertMessage($exp, $this->req("GET", "/feeds"));
$exp = new Response(200, $exp2); $exp = new Response($exp2);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); $this->assertMessage($exp, $this->req("GET", "/feeds"));
} }
public function testAddASubscription() { public function testAddASubscription() {
@ -560,32 +595,32 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
// set up a mock for a bad feed which succeeds the second time // set up a mock for a bad feed which succeeds the second time
Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47); Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47);
// add the subscriptions // add the subscriptions
$exp = new Response(200, $out[0]); $exp = new Response($out[0]);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[0])));
$exp = new Response(200, $out[1]); $exp = new Response($out[1]);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[1])));
// try to add them a second time // try to add them a second time
$exp = new Response(409); $exp = new EmptyResponse(409);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[0])));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[1])));
// try to add a bad feed // try to add a bad feed
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[2])));
// try again (this will succeed), with an invalid folder ID // try again (this will succeed), with an invalid folder ID
$exp = new Response(200, $out[2]); $exp = new Response($out[2]);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[3])));
// try to add no feed // try to add no feed
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[4]), 'application/json'))); $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[4])));
} }
public function testRemoveASubscription() { public function testRemoveASubscription() {
Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); $this->assertMessage($exp, $this->req("DELETE", "/feeds/1"));
// fail on the second invocation because it no longer exists // fail on the second invocation because it no longer exists
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); $this->assertMessage($exp, $this->req("DELETE", "/feeds/1"));
Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1);
} }
@ -603,18 +638,18 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0])));
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[1])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[2])));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/42/move", json_encode($in[3])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[4])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[5])));
} }
public function testRenameASubscription() { public function testRenameASubscription() {
@ -633,18 +668,18 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => ""]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => ""]))->thenThrow(new ExceptionInput("missing"));
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing"));
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0])));
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[1])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[2])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[3])));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/rename", json_encode($in[4]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/42/rename", json_encode($in[4])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[6])));
} }
public function testListStaleFeeds() { public function testListStaleFeeds() {
@ -659,12 +694,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
], ],
]; ];
Phake::when(Arsse::$db)->feedListStale->thenReturn($this->v(array_column($out, "id"))); Phake::when(Arsse::$db)->feedListStale->thenReturn($this->v(array_column($out, "id")));
$exp = new Response(200, ['feeds' => $out]); $exp = new Response(['feeds' => $out]);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); $this->assertMessage($exp, $this->req("GET", "/feeds/all"));
// retrieving the list when not an admin fails // retrieving the list when not an admin fails
Phake::when(Arsse::$user)->rightsGet->thenReturn(0); Phake::when(Arsse::$user)->rightsGet->thenReturn(0);
$exp = new Response(403); $exp = new EmptyResponse(403);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); $this->assertMessage($exp, $this->req("GET", "/feeds/all"));
} }
public function testUpdateAFeed() { public function testUpdateAFeed() {
@ -678,18 +713,18 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true);
Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing"));
Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation"));
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0])));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[1])));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[2])));
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[3])));
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[4]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[4])));
// updating a feed when not an admin fails // updating a feed when not an admin fails
Phake::when(Arsse::$user)->rightsGet->thenReturn(0); Phake::when(Arsse::$user)->rightsGet->thenReturn(0);
$exp = new Response(403); $exp = new EmptyResponse(403);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0])));
} }
public function testListArticles() { public function testListArticles() {
@ -713,25 +748,25 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation"));
$exp = new Response(200, ['items' => $this->articles['rest']]); $exp = new Response(['items' => $this->articles['rest']]);
// check the contents of the response // check the contents of the response
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items/updated"))); // second instance of base context $this->assertMessage($exp, $this->req("GET", "/items/updated")); // second instance of base context
// check error conditions // check error conditions
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[0])));
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[1])));
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[2])));
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json'))); $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[3])));
// simply run through the remainder of the input for later method verification // simply run through the remainder of the input for later method verification
$this->h->dispatch(new Request("GET", "/items", json_encode($in[4]), 'application/json')); $this->req("GET", "/items", json_encode($in[4]));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); // third instance of base context $this->req("GET", "/items", json_encode($in[5])); // third instance of base context
$this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json')); $this->req("GET", "/items", json_encode($in[6]));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[7]), 'application/json')); $this->req("GET", "/items", json_encode($in[7]));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); // fourth instance of base context $this->req("GET", "/items", json_encode($in[8])); // fourth instance of base context
$this->h->dispatch(new Request("GET", "/items", json_encode($in[9]), 'application/json')); $this->req("GET", "/items", json_encode($in[9]));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[10]), 'application/json')); $this->req("GET", "/items", json_encode($in[10]));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[11]), 'application/json')); $this->req("GET", "/items", json_encode($in[11]));
// perform method verifications // perform method verifications
Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL); Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL);
@ -751,14 +786,14 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read", $in, 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=2112"))); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112"));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read"))); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=ook"))); $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=ook"));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/folders/42/read", $in));
} }
public function testMarkASubscriptionRead() { public function testMarkASubscriptionRead() {
@ -766,26 +801,26 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read", $in, 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=2112"))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112"));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read"))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=ook"))); $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=ook"));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/feeds/42/read", $in));
} }
public function testMarkAllItemsRead() { public function testMarkAllItemsRead() {
$read = ['read' => true]; $read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42);
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read", $in, 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/read", $in));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=2112"))); $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112"));
$exp = new Response(422); $exp = new EmptyResponse(422);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read"))); $this->assertMessage($exp, $this->req("PUT", "/items/read"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook"))); $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=ook"));
} }
public function testChangeMarksOfASingleArticle() { public function testChangeMarksOfASingleArticle() {
@ -801,16 +836,16 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(2112))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(2112))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/read"))); $this->assertMessage($exp, $this->req("PUT", "/items/1/read"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/2/unread"))); $this->assertMessage($exp, $this->req("PUT", "/items/2/unread"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/3/star"))); $this->assertMessage($exp, $this->req("PUT", "/items/1/3/star"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/4400/4/unstar"))); $this->assertMessage($exp, $this->req("PUT", "/items/4400/4/unstar"));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/42/read"))); $this->assertMessage($exp, $this->req("PUT", "/items/42/read"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/47/unread"))); $this->assertMessage($exp, $this->req("PUT", "/items/47/unread"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/2112/star"))); $this->assertMessage($exp, $this->req("PUT", "/items/1/2112/star"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/4400/1337/unstar"))); $this->assertMessage($exp, $this->req("PUT", "/items/4400/1337/unstar"));
Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything());
} }
@ -832,27 +867,27 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple"))); $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple"))); $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple"))); $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple"))); $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple"));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => "ook"])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => "ook"])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => "ook"])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => []])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => []])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[0]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[1]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => []])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => []]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => []])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]])));
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]])));
// ensure the data model was queried appropriately for read/unread // ensure the data model was queried appropriately for read/unread
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0]));
@ -885,29 +920,29 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
]; ];
$arr2['warnings']['improperlyConfiguredCron'] = true; $arr2['warnings']['improperlyConfiguredCron'] = true;
$arr2['warnings']['incorrectDbCharset'] = true; $arr2['warnings']['incorrectDbCharset'] = true;
$exp = new Response(200, $arr1); $exp = new Response($arr1);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/status"))); $this->assertMessage($exp, $this->req("GET", "/status"));
} }
public function testCleanUpBeforeUpdate() { public function testCleanUpBeforeUpdate() {
Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true);
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update"));
Phake::verify(Arsse::$db)->feedCleanup(); Phake::verify(Arsse::$db)->feedCleanup();
// performing a cleanup when not an admin fails // performing a cleanup when not an admin fails
Phake::when(Arsse::$user)->rightsGet->thenReturn(0); Phake::when(Arsse::$user)->rightsGet->thenReturn(0);
$exp = new Response(403); $exp = new EmptyResponse(403);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update"));
} }
public function testCleanUpAfterUpdate() { public function testCleanUpAfterUpdate() {
Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true);
$exp = new Response(204); $exp = new EmptyResponse(204);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update"));
Phake::verify(Arsse::$db)->articleCleanup(); Phake::verify(Arsse::$db)->articleCleanup();
// performing a cleanup when not an admin fails // performing a cleanup when not an admin fails
Phake::when(Arsse::$user)->rightsGet->thenReturn(0); Phake::when(Arsse::$user)->rightsGet->thenReturn(0);
$exp = new Response(403); $exp = new EmptyResponse(403);
$this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update"));
} }
} }

View file

@ -7,8 +7,10 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\NextCloudNews; namespace JKingWeb\Arsse\TestCase\REST\NextCloudNews;
use JKingWeb\Arsse\REST\NextCloudNews\Versions; use JKingWeb\Arsse\REST\NextCloudNews\Versions;
use JKingWeb\Arsse\REST\Request; use Psr\Http\Message\ResponseInterface;
use JKingWeb\Arsse\REST\Response; use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */ /** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */
class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest {
@ -16,44 +18,37 @@ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest {
$this->clearData(); $this->clearData();
} }
protected function req(string $method, string $target): ResponseInterface {
$url = "/index.php/apps/news/api".$target;
$server = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $url,
];
$req = new ServerRequest($server, [], $url, $method, "php://memory");
$req = $req->withRequestTarget($target);
return (new Versions)->dispatch($req);
}
public function testFetchVersionList() { public function testFetchVersionList() {
$exp = new Response(200, ['apiLevels' => ['v1-2']]); $exp = new Response(['apiLevels' => ['v1-2']]);
$h = new Versions; $this->assertMessage($exp, $this->req("GET", "/"));
$req = new Request("GET", "/"); $this->assertMessage($exp, $this->req("GET", "/"));
$res = $h->dispatch($req); $this->assertMessage($exp, $this->req("GET", "/"));
$this->assertEquals($exp, $res);
$req = new Request("GET", "");
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
$req = new Request("GET", "/?id=1827");
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
} }
public function testRespondToOptionsRequest() { public function testRespondToOptionsRequest() {
$exp = new Response(204, "", "", ["Allow: HEAD,GET"]); $exp = new EmptyResponse(204, ['Allow' => "HEAD,GET"]);
$h = new Versions; $this->assertMessage($exp, $this->req("OPTIONS", "/"));
$req = new Request("OPTIONS", "/");
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
} }
public function testUseIncorrectMethod() { public function testUseIncorrectMethod() {
$exp = new Response(405, "", "", ["Allow: HEAD,GET"]); $exp = new EmptyResponse(405, ['Allow' => "HEAD,GET"]);
$h = new Versions; $this->assertMessage($exp, $this->req("POST", "/"));
$req = new Request("POST", "/");
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
} }
public function testUseIncorrectPath() { public function testUseIncorrectPath() {
$exp = new Response(404); $exp = new EmptyResponse(404);
$h = new Versions; $this->assertMessage($exp, $this->req("GET", "/ook"));
$req = new Request("GET", "/ook"); $this->assertMessage($exp, $this->req("OPTIONS", "/ook"));
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
$req = new Request("OPTIONS", "/ook");
$res = $h->dispatch($req);
$this->assertEquals($exp, $res);
} }
} }

View file

@ -0,0 +1,334 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\REST;
use JKingWeb\Arsse\REST\Handler;
use JKingWeb\Arsse\REST\Exception501;
use JKingWeb\Arsse\REST\NextCloudNews\V1_2 as NCN;
use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Request;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\TextResponse;
use Zend\Diactoros\Response\EmptyResponse;
use Phake;
/** @covers \JKingWeb\Arsse\REST */
class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideApiMatchData */
public function testMatchAUrlToAnApi($apiList, string $input, array $exp) {
$r = new REST($apiList);
try {
$out = $r->apiMatch($input);
} catch (Exception501 $e) {
$out = [];
}
$this->assertEquals($exp, $out);
}
public function provideApiMatchData() {
$real = null;
$fake = [
'unstripped' => ['match' => "/full/url", 'strip' => "", 'class' => "UnstrippedProtocol"],
];
return [
[$real, "/index.php/apps/news/api/v1-2/feeds", ["ncn_v1-2", "/feeds", \JKingWeb\Arsse\REST\NextCloudNews\V1_2::class]],
[$real, "/index.php/apps/news/api/v1-2", ["ncn", "/v1-2", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
[$real, "/index.php/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
[$real, "/index%2Ephp/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
[$real, "/index.php/apps/news/", []],
[$real, "/index!php/apps/news/api/", []],
[$real, "/tt-rss/api/index.php", ["ttrss_api", "/index.php", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
[$real, "/tt-rss/api", ["ttrss_api", "", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
[$real, "/tt-rss/API", []],
[$real, "/tt-rss/api-bogus", []],
[$real, "/tt-rss/api bogus", []],
[$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
[$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
[$real, "/tt-rss/feed-icons", []],
[$fake, "/full/url/", ["unstripped", "/full/url/", "UnstrippedProtocol"]],
[$fake, "/full/url-not", []],
];
}
/** @dataProvider provideAuthenticableRequests */
public function testAuthenticateRequests(array $serverParams, array $expAttr) {
$r = new REST();
// create a mock user manager
Arsse::$user = Phake::mock(User::class);
Phake::when(Arsse::$user)->auth->thenReturn(true);
Phake::when(Arsse::$user)->auth($this->anything(), "superman")->thenReturn(false);
Phake::when(Arsse::$user)->auth("jane.doe@example.com", $this->anything())->thenReturn(false);
// create an input server request
$req = new ServerRequest($serverParams);
// create the expected output
$exp = $req;
foreach ($expAttr as $key => $value) {
$exp = $exp->withAttribute($key, $value);
}
$act = $r->authenticateRequest($req);
$this->assertMessage($exp, $act);
}
public function provideAuthenticableRequests() {
return [
[['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
[['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret", 'REMOTE_USER' => "jane.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
[['PHP_AUTH_USER' => "jane.doe@example.com", 'PHP_AUTH_PW' => "secret"], []],
[['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "superman"], []],
[['REMOTE_USER' => "john.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
[['REMOTE_USER' => "someone.else@example.com"], ['authenticated' => true, 'authenticatedUser' => "someone.else@example.com"]],
[['REMOTE_USER' => "jane.doe@example.com"], []],
];
}
public function testSendAuthenticationChallenges() {
$this->setConf();
$r = new REST();
$in = new EmptyResponse(401);
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK"');
$act = $r->challenge($in, "OOK");
$this->assertMessage($exp, $act);
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="'.Arsse::$conf->httpRealm.'"');
$act = $r->challenge($in);
$this->assertMessage($exp, $act);
}
/** @dataProvider provideUnnormalizedOrigins */
public function testNormalizeOrigins(string $origin, string $exp, array $ports = null) {
$r = new REST();
$act = $r->corsNormalizeOrigin($origin, $ports);
$this->assertSame($exp, $act);
}
public function provideUnnormalizedOrigins() {
return [
["null", "null"],
["http://example.com", "http://example.com"],
["http://example.com:80", "http://example.com"],
["http://example.com:8%30", "http://example.com"],
["http://example.com:8080", "http://example.com:8080"],
["http://[2001:0db8:0:0:0:0:2:1]", "http://[2001:db8::2:1]"],
["http://example", "http://example"],
["http://ex%41mple", "http://example"],
["http://ex%41mple.co.uk", "http://example.co.uk"],
["http://ex%41mple.co%2euk", "http://example.co%2Euk"],
["http://example/", ""],
["http://example?", ""],
["http://example#", ""],
["http://user@example", ""],
["http://user:pass@example", ""],
["http://[example", ""],
["http://[2bef]", ""],
["http://example%2F", "http://example%2F"],
["HTTP://example", "http://example"],
["HTTP://EXAMPLE", "http://example"],
["%48%54%54%50://example", "http://example"],
["http:%2F%2Fexample", ""],
["https://example", "https://example"],
["https://example:443", "https://example"],
["https://example:80", "https://example:80"],
["ssh://example", "ssh://example"],
["ssh://example:22", "ssh://example:22"],
["ssh://example:22", "ssh://example", ['ssh' => 22]],
["SSH://example:22", "ssh://example", ['ssh' => 22]],
["ssh://example:22", "ssh://example", ['ssh' => "22"]],
["ssh://example:22", "ssh://example:22", ['SSH' => "22"]],
];
}
/** @dataProvider provideCorsNegotiations */
public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null) {
$this->setConf();
$r = Phake::partialMock(REST::class);
Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function ($origin) {
return $origin;
});
$req = new Request("", "GET", "php://memory", ['Origin' => $origin]);
$act = $r->corsNegotiate($req, $allowed, $denied);
$this->assertSame($exp, $act);
}
public function provideCorsNegotiations() {
return [
["http://example", true ],
["http://example", true, "http://example", "*" ],
["http://example", false, "http://example", "http://example"],
["http://example", false, "https://example", "*" ],
["http://example", false, "*", "*" ],
["http://example", true, "*", "" ],
["http://example", false, "", "" ],
["null", false ],
["null", true, "null", "*" ],
["null", false, "null", "null" ],
["null", false, "*", "*" ],
["null", false, "*", "" ],
["null", false, "", "" ],
["", false ],
["", false, "", "*" ],
["", false, "", "" ],
["", false, "*", "*" ],
["", false, "*", "" ],
[["null", "http://example"], false, "*", "" ],
[[], false, "*", "" ],
];
}
/** @dataProvider provideCorsHeaders */
public function testAddCorsHeaders(string $reqMethod, array $reqHeaders, array $resHeaders, array $expHeaders) {
$r = new REST();
$req = new Request("", $reqMethod, "php://memory", $reqHeaders);
$res = new EmptyResponse(204, $resHeaders);
$exp = new EmptyResponse(204, $expHeaders);
$act = $r->corsApply($res, $req);
$this->assertMessage($exp, $act);
}
public function provideCorsHeaders() {
return [
["GET", ['Origin' => "null"], [], [
'Access-Control-Allow-Origin' => "null",
'Access-Control-Allow-Credentials' => "true",
'Vary' => "Origin",
]],
["GET", ['Origin' => "http://example"], [], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Vary' => "Origin",
]],
["GET", ['Origin' => "http://example"], ['Content-Type' => "text/plain; charset=utf-8"], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Vary' => "Origin",
'Content-Type' => "text/plain; charset=utf-8",
]],
["GET", ['Origin' => "http://example"], ['Vary' => "Content-Type"], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Vary' => ["Content-Type", "Origin"],
]],
["OPTIONS", ['Origin' => "http://example"], [], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Access-Control-Max-Age' => (string) (60 *60 *24),
'Vary' => "Origin",
]],
["OPTIONS", ['Origin' => "http://example"], ['Allow' => "GET, PUT, HEAD, OPTIONS"], [
'Allow' => "GET, PUT, HEAD, OPTIONS",
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Access-Control-Allow-Methods' => "GET, PUT, HEAD, OPTIONS",
'Access-Control-Max-Age' => (string) (60 *60 *24),
'Vary' => "Origin",
]],
["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => "Content-Type, If-None-Match"], [], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Access-Control-Allow-Headers' => "Content-Type, If-None-Match",
'Access-Control-Max-Age' => (string) (60 *60 *24),
'Vary' => "Origin",
]],
["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => ["Content-Type", "If-None-Match"]], [], [
'Access-Control-Allow-Origin' => "http://example",
'Access-Control-Allow-Credentials' => "true",
'Access-Control-Allow-Headers' => "Content-Type,If-None-Match",
'Access-Control-Max-Age' => (string) (60 *60 *24),
'Vary' => "Origin",
]],
];
}
/** @dataProvider provideUnnormalizedResponses */
public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) {
$r = Phake::partialMock(REST::class);
Phake::when($r)->corsNegotiate->thenReturn(true);
Phake::when($r)->challenge->thenReturnCallback(function ($res) {
return $res->withHeader("WWW-Authenticate", "Fake Value");
});
Phake::when($r)->corsApply->thenReturnCallback(function ($res) {
return $res;
});
$act = $r->normalizeResponse($res, $req);
$this->assertMessage($exp, $act);
}
public function provideUnnormalizedResponses() {
$stream = fopen("php://memory", "w+b");
fwrite($stream,"ook");
return [
[new EmptyResponse(204), new EmptyResponse(204)],
[new EmptyResponse(401), new EmptyResponse(401, ['WWW-Authenticate' => "Fake Value"])],
[new EmptyResponse(204, ['Allow' => "PUT"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => "PUT,OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => ["PUT", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => ["PUT, DELETE", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, DELETE, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => "HEAD,GET"]), new EmptyResponse(204, ['Allow' => "HEAD, GET, OPTIONS"])],
[new EmptyResponse(204, ['Allow' => "GET"]), new EmptyResponse(204, ['Allow' => "GET, HEAD, OPTIONS"])],
[new TextResponse("ook", 200), new TextResponse("ook", 200, ['Content-Length' => "3"])],
[new TextResponse("", 200), new TextResponse("", 200, ['Content-Length' => "0"])],
[new TextResponse("ook", 404), new TextResponse("ook", 404, ['Content-Length' => "3"])],
[new TextResponse("", 404), new TextResponse("", 404)],
[new Response($stream, 200), new Response($stream, 200, ['Content-Length' => "3"]), new Request("", "GET")],
[new Response($stream, 200), new EmptyResponse(200, ['Content-Length' => "3"]), new Request("", "HEAD")],
];
}
public function testCreateHandlers() {
$r = new REST();
foreach (REST::API_LIST as $api) {
$class = $api['class'];
$this->assertInstanceOf(Handler::class, $r->getHandler($class));
}
}
/** @dataProvider provideMockRequests */
public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") {
$r = Phake::partialMock(REST::class);
Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) {
return $res;
});
Phake::when($r)->authenticateRequest->thenReturnCallback(function ($req) {
return $req;
});
if ($called) {
$h = Phake::mock($class);
Phake::when($r)->getHandler($class)->thenReturn($h);
Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204));
}
$out = $r->dispatch($req);
$this->assertInstanceOf(ResponseInterface::class, $out);
if ($called) {
Phake::verify($r)->authenticateRequest;
Phake::verify($h)->dispatch(Phake::capture($in));
$this->assertSame($method, $in->getMethod());
$this->assertSame($target, $in->getRequestTarget());
} else {
$this->assertSame(501, $out->getStatusCode());
}
Phake::verify($r)->apiMatch;
Phake::verify($r)->normalizeResponse;
}
public function provideMockRequests() {
return [
[new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "GET"), "GET", true, NCN::Class, "/feeds"],
[new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "HEAD"), "GET", true, NCN::Class, "/feeds"],
[new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "get"), "GET", true, NCN::Class, "/feeds"],
[new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "head"), "GET", true, NCN::Class, "/feeds"],
[new ServerRequest([], [], "/tt-rss/api/", "POST"), "POST", true, TTRSS::Class, "/"],
[new ServerRequest([], [], "/no/such/api/", "HEAD"), "GET", false],
[new ServerRequest([], [], "/no/such/api/", "GET"), "GET", false],
];
}
}

View file

@ -0,0 +1,66 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\REST\Target;
/** @covers \JKingWeb\Arsse\REST\Target */
class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideTargetUrls */
public function testParseTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
$test = new Target($target);
$this->assertEquals($path, $test->path, "Path does not match");
$this->assertSame($path, $test->path, "Path does not match exactly");
$this->assertSame($relative, $test->relative, "Relative flag does not match");
$this->assertSame($index, $test->index, "Index flag does not match");
$this->assertSame($query, $test->query, "Query does not match");
$this->assertSame($fragment, $test->fragment, "Fragment does not match");
}
/** @dataProvider provideTargetUrls */
public function testNormalizeTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
$test = new Target("");
$test->path = $path;
$test->relative = $relative;
$test->index = $index;
$test->query = $query;
$test->fragment = $fragment;
$this->assertSame($normalized, (string) $test);
$this->assertSame($normalized, Target::normalize($target));
}
public function provideTargetUrls() {
return [
["/", [], false, true, "", "", "/"],
["", [], true, true, "", "", ""],
["/index.php", ["index.php"], false, false, "", "", "/index.php"],
["index.php", ["index.php"], true, false, "", "", "index.php"],
["/ook/", ["ook"], false, true, "", "", "/ook/"],
["ook/", ["ook"], true, true, "", "", "ook/"],
["/eek/../ook/", ["ook"], false, true, "", "", "/ook/"],
["eek/../ook/", ["ook"], true, true, "", "", "ook/"],
["/./ook/", ["ook"], false, true, "", "", "/ook/"],
["./ook/", ["ook"], true, true, "", "", "ook/"],
["/../ook/", [null,"ook"], false, true, "", "", "/../ook/"],
["../ook/", [null,"ook"], true, true, "", "", "../ook/"],
["0", ["0"], true, false, "", "", "0"],
["%6f%6F%6b", ["ook"], true, false, "", "", "ook"],
["%2e%2E%2f%2E%2Fook%2f", [".././ook/"], true, false, "", "", "..%2F.%2Fook%2F"],
["%2e%2E/%2E/ook%2f", ["..",".","ook/"], true, false, "", "", "%2E%2E/%2E/ook%2F"],
["...", ["..."], true, false, "", "", "..."],
["%2e%2e%2e", ["..."], true, false, "", "", "..."],
["/?", [], false, true, "", "", "/"],
["/#", [], false, true, "", "", "/"],
["/?#", [], false, true, "", "", "/"],
["#%2e", [], true, true, "", ".", "#."],
["?%2e", [], true, true, "%2e", "", "?%2e"],
["?%2e#%2f", [], true, true, "%2e", "/", "?%2e#%2F"],
["#%2e?%2f", [], true, true, "", ".?/", "#.%3F%2F"],
];
}
}

View file

@ -12,13 +12,16 @@ use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response;
use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\TinyTinyRSS\API; use JKingWeb\Arsse\REST\TinyTinyRSS\API;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
use Phake; use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API<extended> /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API<extended>
@ -126,12 +129,26 @@ LONG_STRING;
return $value; return $value;
} }
protected function req($data) : Response { protected function req($data, string $method = "POST", string $target = "", string $strData = null): ResponseInterface {
return $this->h->dispatch(new Request("POST", "", json_encode($data))); $url = "/tt-rss/api".$target;
$server = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $url,
'HTTP_CONTENT_TYPE' => "application/x-www-form-urlencoded",
];
$req = new ServerRequest($server, [], $url, $method, "php://memory");
$body = $req->getBody();
if (!is_null($strData)) {
$body->write($strData);
} else {
$body->write(json_encode($data));
}
$req = $req->withBody($body)->withRequestTarget($target);
return $this->h->dispatch($req);
} }
protected function respGood($content = null, $seq = 0): Response { protected function respGood($content = null, $seq = 0): Response {
return new Response(200, [ return new Response([
'seq' => $seq, 'seq' => $seq,
'status' => 0, 'status' => 0,
'content' => $content, 'content' => $content,
@ -140,18 +157,13 @@ LONG_STRING;
protected function respErr(string $msg, $content = [], $seq = 0): Response { protected function respErr(string $msg, $content = [], $seq = 0): Response {
$err = ['error' => $msg]; $err = ['error' => $msg];
return new Response(200, [ return new Response([
'seq' => $seq, 'seq' => $seq,
'status' => 1, 'status' => 1,
'content' => array_merge($err, $content, $err), 'content' => array_merge($err, $content, $err),
]); ]);
} }
protected function assertResponse(Response $exp, Response $act, string $text = null) {
$this->assertEquals($exp, $act, $text);
$this->assertSame($exp->payload, $act->payload, $text);
}
public function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
Arsse::$conf = new Conf(); Arsse::$conf = new Conf();
@ -179,25 +191,25 @@ LONG_STRING;
public function testHandleInvalidPaths() { public function testHandleInvalidPaths() {
$exp = $this->respErr("MALFORMED_INPUT", [], null); $exp = $this->respErr("MALFORMED_INPUT", [], null);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); $this->assertMessage($exp, $this->req(null, "POST", "", ""));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/", ""))); $this->assertMessage($exp, $this->req(null, "POST", "/", ""));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/index.php", ""))); $this->assertMessage($exp, $this->req(null, "POST", "/index.php", ""));
$exp = new Response(404); $exp = new EmptyResponse(404);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/bad/path", ""))); $this->assertMessage($exp, $this->req(null, "POST", "/bad/path", ""));
} }
public function testHandleOptionsRequest() { public function testHandleOptionsRequest() {
$exp = new Response(204, "", "", [ $exp = new EmptyResponse(204, [
"Allow: POST", 'Allow' => "POST",
"Accept: application/json, text/json", 'Accept' => "application/json, text/json",
]); ]);
$this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", ""))); $this->assertMessage($exp, $this->req(null, "OPTIONS", "", ""));
} }
public function testHandleInvalidData() { public function testHandleInvalidData() {
$exp = $this->respErr("MALFORMED_INPUT", [], null); $exp = $this->respErr("MALFORMED_INPUT", [], null);
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", "This is not valid JSON data"))); $this->assertMessage($exp, $this->req(null, "POST", "", "This is not valid JSON data"));
$this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); // lack of data is also an error $this->assertMessage($exp, $this->req(null, "POST", "", "")); // lack of data is also an error
} }
public function testLogIn() { public function testLogIn() {
@ -210,15 +222,15 @@ LONG_STRING;
'password' => "secret", 'password' => "secret",
]; ];
$exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
// base64 passwords are also accepted // base64 passwords are also accepted
$data['password'] = base64_encode($data['password']); $data['password'] = base64_encode($data['password']);
$exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
// test a failed log-in // test a failed log-in
$data['password'] = "superman"; $data['password'] = "superman";
$exp = $this->respErr("LOGIN_ERROR"); $exp = $this->respErr("LOGIN_ERROR");
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
// logging in should never try to resume a session // logging in should never try to resume a session
Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything());
} }
@ -230,8 +242,8 @@ LONG_STRING;
'user' => Arsse::$user->id, 'user' => Arsse::$user->id,
'password' => "secret", 'password' => "secret",
]; ];
$exp = new Response(500); $exp = new EmptyResponse(500);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testLogOut() { public function testLogOut() {
@ -241,7 +253,7 @@ LONG_STRING;
'sid' => "PriestsOfSyrinx", 'sid' => "PriestsOfSyrinx",
]; ];
$exp = $this->respGood(['status' => "OK"]); $exp = $this->respGood(['status' => "OK"]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx");
} }
@ -251,10 +263,10 @@ LONG_STRING;
'sid' => "PriestsOfSyrinx", 'sid' => "PriestsOfSyrinx",
]; ];
$exp = $this->respGood(['status' => true]); $exp = $this->respGood(['status' => true]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
$data['sid'] = "SolarFederation"; $data['sid'] = "SolarFederation";
$exp = $this->respErr("NOT_LOGGED_IN"); $exp = $this->respErr("NOT_LOGGED_IN");
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testHandleUnknownMethods() { public function testHandleUnknownMethods() {
@ -263,7 +275,7 @@ LONG_STRING;
'op' => "thisMethodDoesNotExist", 'op' => "thisMethodDoesNotExist",
'sid' => "PriestsOfSyrinx", 'sid' => "PriestsOfSyrinx",
]; ];
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testHandleMixedCaseMethods() { public function testHandleMixedCaseMethods() {
@ -272,13 +284,13 @@ LONG_STRING;
'sid' => "PriestsOfSyrinx", 'sid' => "PriestsOfSyrinx",
]; ];
$exp = $this->respGood(['status' => true]); $exp = $this->respGood(['status' => true]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
$data['op'] = "isloggedin"; $data['op'] = "isloggedin";
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
$data['op'] = "ISLOGGEDIN"; $data['op'] = "ISLOGGEDIN";
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
$data['op'] = "iSlOgGeDiN"; $data['op'] = "iSlOgGeDiN";
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testRetrieveServerVersion() { public function testRetrieveServerVersion() {
@ -290,7 +302,7 @@ LONG_STRING;
'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION,
'arsse_version' => Arsse::VERSION, 'arsse_version' => Arsse::VERSION,
]); ]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testRetrieveProtocolLevel() { public function testRetrieveProtocolLevel() {
@ -299,7 +311,7 @@ LONG_STRING;
'sid' => "PriestsOfSyrinx", 'sid' => "PriestsOfSyrinx",
]; ];
$exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]);
$this->assertResponse($exp, $this->req($data)); $this->assertMessage($exp, $this->req($data));
} }
public function testAddACategory() { public function testAddACategory() {
@ -333,24 +345,24 @@ LONG_STRING;
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace"));
// correctly add two folders // correctly add two folders
$exp = $this->respGood("2"); $exp = $this->respGood("2");
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$exp = $this->respGood("3"); $exp = $this->respGood("3");
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// attempt to add the two folders again // attempt to add the two folders again
$exp = $this->respGood("2"); $exp = $this->respGood("2");
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$exp = $this->respGood("3"); $exp = $this->respGood("3");
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false);
Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false);
// add a folder to a missing parent (silently fails) // add a folder to a missing parent (silently fails)
$exp = $this->respGood(false); $exp = $this->respGood(false);
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
// add some invalid folders // add some invalid folders
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
} }
public function testRemoveACategory() { public function testRemoveACategory() {
@ -363,16 +375,16 @@ LONG_STRING;
Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
// succefully delete a folder // succefully delete a folder
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// try deleting it again (this should silently fail) // try deleting it again (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// delete a folder which does not exist (this should also silently fail) // delete a folder which does not exist (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// delete an invalid folder (causes an error) // delete an invalid folder (causes an error)
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything());
} }
@ -410,21 +422,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation"));
// succefully move a folder // succefully move a folder
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// move a folder which does not exist (this should silently fail) // move a folder which does not exist (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// move a folder causing a duplication (this should also silently fail) // move a folder causing a duplication (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
// all the rest should cause errors // all the rest should cause errors
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[7])); $this->assertMessage($exp, $this->req($in[7]));
$this->assertResponse($exp, $this->req($in[8])); $this->assertMessage($exp, $this->req($in[8]));
Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
} }
@ -450,21 +462,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
// succefully rename a folder // succefully rename a folder
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// rename a folder which does not exist (this should silently fail) // rename a folder which does not exist (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// rename a folder causing a duplication (this should also silently fail) // rename a folder causing a duplication (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
// all the rest should cause errors // all the rest should cause errors
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
$this->assertResponse($exp, $this->req($in[7])); $this->assertMessage($exp, $this->req($in[7]));
$this->assertResponse($exp, $this->req($in[8])); $this->assertMessage($exp, $this->req($in[8]));
Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
} }
@ -534,11 +546,11 @@ LONG_STRING;
Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v($list))); Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v($list)));
for ($a = 0; $a < (sizeof($in) - 4); $a++) { for ($a = 0; $a < (sizeof($in) - 4); $a++) {
$exp = $this->respGood($out[$a]); $exp = $this->respGood($out[$a]);
$this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a");
} }
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) { for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) {
$this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a");
} }
Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]); Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]);
} }
@ -555,13 +567,13 @@ LONG_STRING;
Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
// succefully delete a folder // succefully delete a folder
$exp = $this->respGood(['status' => "OK"]); $exp = $this->respGood(['status' => "OK"]);
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// try deleting it again (this should noisily fail, as should everything else) // try deleting it again (this should noisily fail, as should everything else)
$exp = $this->respErr("FEED_NOT_FOUND"); $exp = $this->respErr("FEED_NOT_FOUND");
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything());
} }
@ -589,21 +601,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation"));
// succefully move a subscription // succefully move a subscription
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// move a subscription which does not exist (this should silently fail) // move a subscription which does not exist (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// move a subscription causing a duplication (this should also silently fail) // move a subscription causing a duplication (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
// all the rest should cause errors // all the rest should cause errors
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
$this->assertResponse($exp, $this->req($in[7])); $this->assertMessage($exp, $this->req($in[7]));
$this->assertResponse($exp, $this->req($in[8])); $this->assertMessage($exp, $this->req($in[8]));
Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
} }
@ -629,21 +641,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
// succefully rename a subscription // succefully rename a subscription
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// rename a subscription which does not exist (this should silently fail) // rename a subscription which does not exist (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// rename a subscription causing a duplication (this should also silently fail) // rename a subscription causing a duplication (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
// all the rest should cause errors // all the rest should cause errors
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
$this->assertResponse($exp, $this->req($in[7])); $this->assertMessage($exp, $this->req($in[7]));
$this->assertResponse($exp, $this->req($in[8])); $this->assertMessage($exp, $this->req($in[8]));
Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[0]); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[0]);
Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[1]); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[1]);
Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[2]); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[2]);
@ -657,7 +669,7 @@ LONG_STRING;
['id' => 3, 'unread' => 47], ['id' => 3, 'unread' => 47],
]))); ])));
$exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]); $exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]);
$this->assertResponse($exp, $this->req($in)); $this->assertMessage($exp, $this->req($in));
} }
public function testRetrieveTheServerConfiguration() { public function testRetrieveTheServerConfiguration() {
@ -671,8 +683,8 @@ LONG_STRING;
['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12], ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12],
['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2], ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2],
]; ];
$this->assertResponse($this->respGood($exp[0]), $this->req($in)); $this->assertMessage($this->respGood($exp[0]), $this->req($in));
$this->assertResponse($this->respGood($exp[1]), $this->req($in)); $this->assertMessage($this->respGood($exp[1]), $this->req($in));
} }
public function testUpdateAFeed() { public function testUpdateAFeed() {
@ -686,13 +698,13 @@ LONG_STRING;
Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v(['id' => 1, 'feed' => 11])); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v(['id' => 1, 'feed' => 11]));
Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = $this->respGood(['status' => "OK"]); $exp = $this->respGood(['status' => "OK"]);
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
Phake::verify(Arsse::$db)->feedUpdate(11); Phake::verify(Arsse::$db)->feedUpdate(11);
$exp = $this->respErr("FEED_NOT_FOUND"); $exp = $this->respErr("FEED_NOT_FOUND");
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
} }
public function testAddALabel() { public function testAddALabel() {
@ -723,21 +735,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace"));
// correctly add two labels // correctly add two labels
$exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2);
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3);
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// attempt to add the two labels again // attempt to add the two labels again
$exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2);
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3);
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true);
Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true);
// add some invalid labels // add some invalid labels
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
} }
public function testRemoveALabel() { public function testRemoveALabel() {
@ -752,18 +764,18 @@ LONG_STRING;
Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
// succefully delete a label // succefully delete a label
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// try deleting it again (this should silently fail) // try deleting it again (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// delete a label which does not exist (this should also silently fail) // delete a label which does not exist (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// delete some invalid labels (causes an error) // delete some invalid labels (causes an error)
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 18); Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 18);
Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 1088); Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 1088);
} }
@ -796,21 +808,21 @@ LONG_STRING;
Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation"));
// succefully rename a label // succefully rename a label
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
// rename a label which does not exist (this should silently fail) // rename a label which does not exist (this should silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
// rename a label causing a duplication (this should also silently fail) // rename a label causing a duplication (this should also silently fail)
$exp = $this->respGood(); $exp = $this->respGood();
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
// all the rest should cause errors // all the rest should cause errors
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
$this->assertResponse($exp, $this->req($in[7])); $this->assertMessage($exp, $this->req($in[7]));
$this->assertResponse($exp, $this->req($in[8])); $this->assertMessage($exp, $this->req($in[8]));
Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
} }
@ -882,7 +894,7 @@ LONG_STRING;
], ],
]; ];
for ($a = 0; $a < sizeof($in); $a++) { for ($a = 0; $a < sizeof($in); $a++) {
$this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed");
} }
} }
@ -918,7 +930,7 @@ LONG_STRING;
['id' => 0, 'kind' => "cat", 'counter' => 0], ['id' => 0, 'kind' => "cat", 'counter' => 0],
['id' => -2, 'kind' => "cat", 'counter' => 6], ['id' => -2, 'kind' => "cat", 'counter' => 6],
]; ];
$this->assertResponse($this->respGood($exp), $this->req($in)); $this->assertMessage($this->respGood($exp), $this->req($in));
} }
public function testRetrieveTheLabelList() { public function testRetrieveTheLabelList() {
@ -962,7 +974,7 @@ LONG_STRING;
], ],
]; ];
for ($a = 0; $a < sizeof($in); $a++) { for ($a = 0; $a < sizeof($in); $a++) {
$this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed");
} }
} }
@ -988,20 +1000,20 @@ LONG_STRING;
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5);
Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2);
$exp = $this->respGood(['status' => "OK", 'updated' => 89]); $exp = $this->respGood(['status' => "OK", 'updated' => 89]);
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true);
$exp = $this->respGood(['status' => "OK", 'updated' => 7]); $exp = $this->respGood(['status' => "OK", 'updated' => 7]);
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false);
Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false);
$exp = $this->respGood(['status' => "OK", 'updated' => 0]); $exp = $this->respGood(['status' => "OK", 'updated' => 0]);
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$this->assertResponse($exp, $this->req($in[4])); $this->assertMessage($exp, $this->req($in[4]));
$this->assertResponse($exp, $this->req($in[5])); $this->assertMessage($exp, $this->req($in[5]));
$this->assertResponse($exp, $this->req($in[6])); $this->assertMessage($exp, $this->req($in[6]));
} }
public function testRetrieveFeedTree() { public function testRetrieveFeedTree() {
@ -1016,9 +1028,9 @@ LONG_STRING;
Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
// the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them // the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them
$exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Photography','id'=>'CAT:4','bare_id'=>4,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(0 feeds)','items'=>[],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Photography','id'=>'CAT:4','bare_id'=>4,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(0 feeds)','items'=>[],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],];
$this->assertResponse($this->respGood($exp), $this->req($in[0])); $this->assertMessage($this->respGood($exp), $this->req($in[0]));
$exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],];
$this->assertResponse($this->respGood($exp), $this->req($in[1])); $this->assertMessage($this->respGood($exp), $this->req($in[1]));
} }
public function testMarkFeedsAsRead() { public function testMarkFeedsAsRead() {
@ -1050,12 +1062,12 @@ LONG_STRING;
$exp = $this->respGood(['status' => "OK"]); $exp = $this->respGood(['status' => "OK"]);
// verify the above are in fact no-ops // verify the above are in fact no-ops
for ($a = 0; $a < sizeof($in1); $a++) { for ($a = 0; $a < sizeof($in1); $a++) {
$this->assertResponse($exp, $this->req($in1[$a]), "Test $a failed"); $this->assertMessage($exp, $this->req($in1[$a]), "Test $a failed");
} }
Phake::verify(Arsse::$db, Phake::times(0))->articleMark; Phake::verify(Arsse::$db, Phake::times(0))->articleMark;
// verify the simple contexts // verify the simple contexts
for ($a = 0; $a < sizeof($in2); $a++) { for ($a = 0; $a < sizeof($in2); $a++) {
$this->assertResponse($exp, $this->req($in2[$a]), "Test $a failed"); $this->assertMessage($exp, $this->req($in2[$a]), "Test $a failed");
} }
Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context); Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context);
Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true)); Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true));
@ -1067,7 +1079,7 @@ LONG_STRING;
// verify the time-based mock // verify the time-based mock
$t = Date::sub("PT24H"); $t = Date::sub("PT24H");
for ($a = 0; $a < sizeof($in3); $a++) { for ($a = 0; $a < sizeof($in3); $a++) {
$this->assertResponse($exp, $this->req($in3[$a]), "Test $a failed"); $this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed");
} }
Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t));
} }
@ -1202,10 +1214,10 @@ LONG_STRING;
], ],
]; ];
for ($a = 0; $a < sizeof($in1); $a++) { for ($a = 0; $a < sizeof($in1); $a++) {
$this->assertResponse($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed"); $this->assertMessage($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed");
} }
for ($a = 0; $a < sizeof($in2); $a++) { for ($a = 0; $a < sizeof($in2); $a++) {
$this->assertResponse($this->respGood([]), $this->req($in2[$a]), "Test $a failed"); $this->assertMessage($this->respGood([]), $this->req($in2[$a]), "Test $a failed");
} }
} }
@ -1315,7 +1327,7 @@ LONG_STRING;
$this->respErr("INCORRECT_USAGE"), $this->respErr("INCORRECT_USAGE"),
]; ];
for ($a = 0; $a < sizeof($in); $a++) { for ($a = 0; $a < sizeof($in); $a++) {
$this->assertResponse($out[$a], $this->req($in[$a]), "Test $a failed"); $this->assertMessage($out[$a], $this->req($in[$a]), "Test $a failed");
} }
} }
@ -1339,10 +1351,10 @@ LONG_STRING;
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result($this->v([$this->articles[0]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result($this->v([$this->articles[0]])));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result($this->v([$this->articles[1]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result($this->v([$this->articles[1]])));
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertResponse($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[0]));
$this->assertResponse($exp, $this->req($in[1])); $this->assertMessage($exp, $this->req($in[1]));
$this->assertResponse($exp, $this->req($in[2])); $this->assertMessage($exp, $this->req($in[2]));
$this->assertResponse($exp, $this->req($in[3])); $this->assertMessage($exp, $this->req($in[3]));
$exp = [ $exp = [
[ [
'id' => "101", 'id' => "101",
@ -1399,13 +1411,13 @@ LONG_STRING;
'content' => '<p>Article content 2</p>', 'content' => '<p>Article content 2</p>',
], ],
]; ];
$this->assertResponse($this->respGood($exp), $this->req($in[4])); $this->assertMessage($this->respGood($exp), $this->req($in[4]));
$this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5]));
$this->assertResponse($this->respGood([$exp[1]]), $this->req($in[6])); $this->assertMessage($this->respGood([$exp[1]]), $this->req($in[6]));
// test the special case when labels are not used // test the special case when labels are not used
Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([])); Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([]));
Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([])); Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([]));
$this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5]));
} }
public function testRetrieveCompactHeadlines() { public function testRetrieveCompactHeadlines() {
@ -1484,13 +1496,13 @@ LONG_STRING;
$this->respGood([['id' => 1003]]), $this->respGood([['id' => 1003]]),
]; ];
for ($a = 0; $a < sizeof($in1); $a++) { for ($a = 0; $a < sizeof($in1); $a++) {
$this->assertResponse($out1[$a], $this->req($in1[$a]), "Test $a failed"); $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed");
} }
for ($a = 0; $a < sizeof($in2); $a++) { for ($a = 0; $a < sizeof($in2); $a++) {
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1001]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1001]])));
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1002]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1002]])));
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1003]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1003]])));
$this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
} }
} }
@ -1592,16 +1604,16 @@ LONG_STRING;
$this->outputHeadlines(1003), $this->outputHeadlines(1003),
]; ];
for ($a = 0; $a < sizeof($in1); $a++) { for ($a = 0; $a < sizeof($in1); $a++) {
$this->assertResponse($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); $this->assertMessage($this->respGood([]), $this->req($in1[$a]), "Test $a failed");
} }
for ($a = 0; $a < sizeof($in2); $a++) { for ($a = 0; $a < sizeof($in2); $a++) {
$this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
} }
for ($a = 0; $a < sizeof($in3); $a++) { for ($a = 0; $a < sizeof($in3); $a++) {
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1001)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1001));
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1002)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1002));
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1003)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1003));
$this->assertResponse($out3[$a], $this->req($in3[$a]), "Test $a failed"); $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed");
} }
} }
@ -1631,13 +1643,13 @@ LONG_STRING;
Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
// sanity check; this makes sure extra fields are not included in default situations // sanity check; this makes sure extra fields are not included in default situations
$test = $this->req($in[0]); $test = $this->req($in[0]);
$this->assertResponse($this->outputHeadlines(1), $test); $this->assertMessage($this->outputHeadlines(1), $test);
// test 'show_content' // test 'show_content'
$test = $this->req($in[1]); $test = $this->req($in[1]);
$this->assertArrayHasKey("content", $test->payload['content'][0]); $this->assertArrayHasKey("content", $test->getPayload()['content'][0]);
$this->assertArrayHasKey("content", $test->payload['content'][1]); $this->assertArrayHasKey("content", $test->getPayload()['content'][1]);
foreach ($this->generateHeadlines(1) as $key => $row) { foreach ($this->generateHeadlines(1) as $key => $row) {
$this->assertSame($row['content'], $test->payload['content'][$key]['content']); $this->assertSame($row['content'], $test->getPayload()['content'][$key]['content']);
} }
// test 'include_attachments' // test 'include_attachments'
$test = $this->req($in[2]); $test = $this->req($in[2]);
@ -1653,33 +1665,31 @@ LONG_STRING;
'post_id' => "2112", 'post_id' => "2112",
], ],
]; ];
$this->assertArrayHasKey("attachments", $test->payload['content'][0]); $this->assertArrayHasKey("attachments", $test->getPayload()['content'][0]);
$this->assertArrayHasKey("attachments", $test->payload['content'][1]); $this->assertArrayHasKey("attachments", $test->getPayload()['content'][1]);
$this->assertSame([], $test->payload['content'][0]['attachments']); $this->assertSame([], $test->getPayload()['content'][0]['attachments']);
$this->assertSame($exp, $test->payload['content'][1]['attachments']); $this->assertSame($exp, $test->getPayload()['content'][1]['attachments']);
// test 'include_header' // test 'include_header'
$test = $this->req($in[3]); $test = $this->req($in[3]);
$exp = $this->outputHeadlines(1); $exp = $this->respGood([
$exp->payload['content'] = [
['id' => -4, 'is_cat' => false, 'first_id' => 1], ['id' => -4, 'is_cat' => false, 'first_id' => 1],
$exp->payload['content'], $this->outputHeadlines(1)->getPayload()['content'],
]; ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with a category // test 'include_header' with a category
$test = $this->req($in[4]); $test = $this->req($in[4]);
$exp = $this->outputHeadlines(1); $exp = $this->respGood([
$exp->payload['content'] = [
['id' => -3, 'is_cat' => true, 'first_id' => 1], ['id' => -3, 'is_cat' => true, 'first_id' => 1],
$exp->payload['content'], $this->outputHeadlines(1)->getPayload()['content'],
]; ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with an empty result // test 'include_header' with an empty result
$test = $this->req($in[5]); $test = $this->req($in[5]);
$exp = $this->respGood([ $exp = $this->respGood([
['id' => -1, 'is_cat' => true, 'first_id' => 0], ['id' => -1, 'is_cat' => true, 'first_id' => 0],
[], [],
]); ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with an erroneous result // 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)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing"));
$test = $this->req($in[6]); $test = $this->req($in[6]);
@ -1687,40 +1697,37 @@ LONG_STRING;
['id' => 2112, 'is_cat' => false, 'first_id' => 0], ['id' => 2112, 'is_cat' => false, 'first_id' => 0],
[], [],
]); ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with ascending order // test 'include_header' with ascending order
$test = $this->req($in[7]); $test = $this->req($in[7]);
$exp = $this->outputHeadlines(1); $exp = $this->respGood([
$exp->payload['content'] = [
['id' => -4, 'is_cat' => false, 'first_id' => 0], ['id' => -4, 'is_cat' => false, 'first_id' => 0],
$exp->payload['content'], $this->outputHeadlines(1)->getPayload()['content'],
]; ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with skip // test 'include_header' with skip
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867)); Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867));
$test = $this->req($in[8]); $test = $this->req($in[8]);
$exp = $this->outputHeadlines(1); $exp = $this->respGood([
$exp->payload['content'] = [
['id' => 42, 'is_cat' => false, 'first_id' => 1867], ['id' => 42, 'is_cat' => false, 'first_id' => 1867],
$exp->payload['content'], $this->outputHeadlines(1)->getPayload()['content'],
]; ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'include_header' with skip and ascending order // test 'include_header' with skip and ascending order
$test = $this->req($in[9]); $test = $this->req($in[9]);
$exp = $this->outputHeadlines(1); $exp = $this->respGood([
$exp->payload['content'] = [
['id' => 42, 'is_cat' => false, 'first_id' => 0], ['id' => 42, 'is_cat' => false, 'first_id' => 0],
$exp->payload['content'], $this->outputHeadlines(1)->getPayload()['content'],
]; ]);
$this->assertResponse($exp, $test); $this->assertMessage($exp, $test);
// test 'show_excerpt' // test 'show_excerpt'
$exp1 = "“This & that, you know‽”"; $exp1 = "“This & that, you know‽”";
$exp2 = "Pour vous faire mieux connaitre dou\u{300} vient lerreur de ceux qui bla\u{302}ment la volupte\u{301}, et qui louent en…"; $exp2 = "Pour vous faire mieux connaitre dou\u{300} vient lerreur de ceux qui bla\u{302}ment la volupte\u{301}, et qui louent en…";
$test = $this->req($in[10]); $test = $this->req($in[10]);
$this->assertArrayHasKey("excerpt", $test->payload['content'][0]); $this->assertArrayHasKey("excerpt", $test->getPayload()['content'][0]);
$this->assertArrayHasKey("excerpt", $test->payload['content'][1]); $this->assertArrayHasKey("excerpt", $test->getPayload()['content'][1]);
$this->assertSame($exp1, $test->payload['content'][0]['excerpt']); $this->assertSame($exp1, $test->getPayload()['content'][0]['excerpt']);
$this->assertSame($exp2, $test->payload['content'][1]['excerpt']); $this->assertSame($exp2, $test->getPayload()['content'][1]['excerpt']);
} }
protected function generateHeadlines(int $id): Result { protected function generateHeadlines(int $id): Result {

View file

@ -12,7 +12,9 @@ use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\REST\TinyTinyRSS\Icon; use JKingWeb\Arsse\REST\TinyTinyRSS\Icon;
use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response; use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\EmptyResponse as Response;
use Phake; use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon<extended> */ /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon<extended> */
@ -32,26 +34,37 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
$this->clearData(); $this->clearData();
} }
protected function req(string $target, $method = "GET"): ResponseInterface {
$url = "/tt-rss/feed-icons/".$target;
$server = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $url,
];
$req = new ServerRequest($server, [], $url, $method, "php://memory");
$req = $req->withRequestTarget($target);
return $this->h->dispatch($req);
}
public function testRetrieveFavion() { public function testRetrieveFavion() {
Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico"); Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico");
Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png"); Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png");
Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/");
// these requests should succeed // these requests should succeed
$exp = new Response(301, "", "", ["Location: http://example.com/favicon.ico"]); $exp = new Response(301, ['Location' => "http://example.com/favicon.ico"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "42.ico"))); $this->assertMessage($exp, $this->req("42.ico"));
$exp = new Response(301, "", "", ["Location: http://example.net/logo.png"]); $exp = new Response(301, ['Location' => "http://example.net/logo.png"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); $this->assertMessage($exp, $this->req("2112.ico"));
$exp = new Response(301, "", "", ["Location: http://example.org/icon.gif"]); $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); $this->assertMessage($exp, $this->req("1337.ico"));
// these requests should fail // these requests should fail
$exp = new Response(404); $exp = new Response(404);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); $this->assertMessage($exp, $this->req("ook.ico"));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook"))); $this->assertMessage($exp, $this->req("ook"));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "47.ico"))); $this->assertMessage($exp, $this->req("47.ico"));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.png"))); $this->assertMessage($exp, $this->req("2112.png"));
// only GET is allowed // only GET is allowed
$exp = new Response(405, "", "", ["Allow: GET"]); $exp = new Response(405, ['Allow' => "GET"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); $this->assertMessage($exp, $this->req("2112.ico", "PUT"));
} }
} }

View file

@ -8,10 +8,29 @@ namespace JKingWeb\Arsse\Test;
use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Exception;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Response\EmptyResponse;
/** @coversNothing */ /** @coversNothing */
abstract class AbstractTest extends \PHPUnit\Framework\TestCase { abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
public function setUp() {
$this->clearData();
}
public function tearDown() {
$this->clearData();
}
public function setConf(array $conf = []) {
Arsse::$conf = (new Conf)->import($conf);
}
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
if (func_num_args()) { if (func_num_args()) {
$class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
@ -29,6 +48,28 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
} }
} }
protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = null) {
if ($exp instanceof ResponseInterface) {
$this->assertInstanceOf(ResponseInterface::class, $act, $text);
$this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text);
} elseif ($exp instanceof RequestInterface) {
if ($exp instanceof ServerRequestInterface) {
$this->assertInstanceOf(ServerRequestInterface::class, $act, $text);
$this->assertEquals($exp->getAttributes(), $act->getAttributes(), $text);
}
$this->assertInstanceOf(RequestInterface::class, $act, $text);
$this->assertSame($exp->getMethod(), $act->getMethod(), $text);
$this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text);
}
if ($exp instanceof JsonResponse) {
$this->assertEquals($exp->getPayload(), $act->getPayload(), $text);
$this->assertSame($exp->getPayload(), $act->getPayload(), $text);
} else {
$this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text);
}
$this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text);
}
public function approximateTime($exp, $act) { public function approximateTime($exp, $act) {
if (is_null($act)) { if (is_null($act)) {
return null; return null;

View file

@ -82,17 +82,19 @@
<file>cases/Db/SQLite3PDO/Database/TestLabel.php</file> <file>cases/Db/SQLite3PDO/Database/TestLabel.php</file>
<file>cases/Db/SQLite3PDO/Database/TestCleanup.php</file> <file>cases/Db/SQLite3PDO/Database/TestCleanup.php</file>
</testsuite> </testsuite>
<testsuite name="Controllers"> <testsuite name="REST">
<testsuite name="NCNv1"> <file>cases/REST/TestTarget.php</file>
<file>cases/REST/NextCloudNews/TestVersions.php</file> <file>cases/REST/TestREST.php</file>
<file>cases/REST/NextCloudNews/TestV1_2.php</file> </testsuite>
<testsuite name="NCNv1">
<file>cases/REST/NextCloudNews/TestVersions.php</file>
<file>cases/REST/NextCloudNews/TestV1_2.php</file>
<file>cases/REST/NextCloudNews/PDO/TestV1_2.php</file> <file>cases/REST/NextCloudNews/PDO/TestV1_2.php</file>
</testsuite> </testsuite>
<testsuite name="TTRSS"> <testsuite name="TTRSS">
<file>cases/REST/TinyTinyRSS/TestAPI.php</file> <file>cases/REST/TinyTinyRSS/TestAPI.php</file>
<file>cases/REST/TinyTinyRSS/TestIcon.php</file> <file>cases/REST/TinyTinyRSS/TestIcon.php</file>
<file>cases/REST/TinyTinyRSS/PDO/TestAPI.php</file> <file>cases/REST/TinyTinyRSS/PDO/TestAPI.php</file>
</testsuite>
</testsuite> </testsuite>
<testsuite name="Refresh service"> <testsuite name="Refresh service">
<file>cases/Service/TestService.php</file> <file>cases/Service/TestService.php</file>

View file

@ -777,16 +777,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "6.5.4", "version": "6.5.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "1b2f933d5775f9237369deaa2d2bfbf9d652be4c" "reference": "83d27937a310f2984fd575686138597147bdc7df"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1b2f933d5775f9237369deaa2d2bfbf9d652be4c", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d27937a310f2984fd575686138597147bdc7df",
"reference": "1b2f933d5775f9237369deaa2d2bfbf9d652be4c", "reference": "83d27937a310f2984fd575686138597147bdc7df",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -857,7 +857,7 @@
"testing", "testing",
"xunit" "xunit"
], ],
"time": "2017-12-10T08:06:19+00:00" "time": "2017-12-17T06:31:19+00:00"
}, },
{ {
"name": "phpunit/phpunit-mock-objects", "name": "phpunit/phpunit-mock-objects",
@ -965,16 +965,16 @@
}, },
{ {
"name": "sebastian/comparator", "name": "sebastian/comparator",
"version": "2.1.0", "version": "2.1.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git", "url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "1174d9018191e93cb9d719edec01257fc05f8158" "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1174d9018191e93cb9d719edec01257fc05f8158", "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b11c729f95109b56a0fe9650c6a63a0fcd8c439f",
"reference": "1174d9018191e93cb9d719edec01257fc05f8158", "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1025,7 +1025,7 @@
"compare", "compare",
"equality" "equality"
], ],
"time": "2017-11-03T07:16:52+00:00" "time": "2017-12-22T14:50:35+00:00"
}, },
{ {
"name": "sebastian/diff", "name": "sebastian/diff",

View file

@ -59,28 +59,33 @@
}, },
{ {
"name": "consolidation/config", "name": "consolidation/config",
"version": "1.0.7", "version": "1.0.9",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/consolidation/config.git", "url": "https://github.com/consolidation/config.git",
"reference": "b59a3b9ea750c21397f26a68fd2e04d9580af42e" "reference": "34ca8d7c1ee60a7b591b10617114cf1210a2e92c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/consolidation/config/zipball/b59a3b9ea750c21397f26a68fd2e04d9580af42e", "url": "https://api.github.com/repos/consolidation/config/zipball/34ca8d7c1ee60a7b591b10617114cf1210a2e92c",
"reference": "b59a3b9ea750c21397f26a68fd2e04d9580af42e", "reference": "34ca8d7c1ee60a7b591b10617114cf1210a2e92c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"dflydev/dot-access-data": "^1.1.0", "dflydev/dot-access-data": "^1.1.0",
"grasmash/yaml-expander": "^1.1", "grasmash/expander": "^1",
"php": ">=5.4.0" "php": ">=5.4.0"
}, },
"require-dev": { "require-dev": {
"greg-1-anderson/composer-test-scenarios": "^1",
"phpunit/phpunit": "^4", "phpunit/phpunit": "^4",
"satooshi/php-coveralls": "^1.0", "satooshi/php-coveralls": "^1.0",
"squizlabs/php_codesniffer": "2.*", "squizlabs/php_codesniffer": "2.*",
"symfony/console": "^2.5|^3" "symfony/console": "^2.5|^3|^4",
"symfony/yaml": "^2.8.11|^3|^4"
},
"suggest": {
"symfony/yaml": "Required to use Consolidation\\Config\\Loader\\YamlConfigLoader"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@ -104,7 +109,7 @@
} }
], ],
"description": "Provide configuration services for a commandline tool.", "description": "Provide configuration services for a commandline tool.",
"time": "2017-10-25T05:50:10+00:00" "time": "2017-12-22T17:28:19+00:00"
}, },
{ {
"name": "consolidation/log", "name": "consolidation/log",
@ -205,16 +210,16 @@
}, },
{ {
"name": "consolidation/robo", "name": "consolidation/robo",
"version": "1.2.0", "version": "1.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/consolidation/Robo.git", "url": "https://github.com/consolidation/Robo.git",
"reference": "c46c13de3eca55e6b3635f363688ce85e845adf0" "reference": "b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/consolidation/Robo/zipball/c46c13de3eca55e6b3635f363688ce85e845adf0", "url": "https://api.github.com/repos/consolidation/Robo/zipball/b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9",
"reference": "c46c13de3eca55e6b3635f363688ce85e845adf0", "reference": "b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -278,7 +283,7 @@
} }
], ],
"description": "Modern task runner", "description": "Modern task runner",
"time": "2017-12-13T02:10:49+00:00" "time": "2017-12-29T06:48:35+00:00"
}, },
{ {
"name": "container-interop/container-interop", "name": "container-interop/container-interop",
@ -370,6 +375,53 @@
], ],
"time": "2017-01-20T21:14:22+00:00" "time": "2017-01-20T21:14:22+00:00"
}, },
{
"name": "grasmash/expander",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/grasmash/expander.git",
"reference": "95d6037344a4be1dd5f8e0b0b2571a28c397578f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/grasmash/expander/zipball/95d6037344a4be1dd5f8e0b0b2571a28c397578f",
"reference": "95d6037344a4be1dd5f8e0b0b2571a28c397578f",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^1.1.0",
"php": ">=5.4"
},
"require-dev": {
"greg-1-anderson/composer-test-scenarios": "^1",
"phpunit/phpunit": "^4|^5.5.4",
"satooshi/php-coveralls": "^1.0.2|dev-master",
"squizlabs/php_codesniffer": "^2.7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Grasmash\\Expander\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matthew Grasmick"
}
],
"description": "Expands internal property references in PHP arrays file.",
"time": "2017-12-21T22:14:55+00:00"
},
{ {
"name": "grasmash/yaml-expander", "name": "grasmash/yaml-expander",
"version": "1.4.0", "version": "1.4.0",