J. King
5 years ago
114 changed files with 7700 additions and 1771 deletions
@ -0,0 +1,56 @@ |
|||
<?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\Context; |
|||
|
|||
class Context extends ExclusionContext { |
|||
/** @var ExclusionContext */ |
|||
public $not; |
|||
public $limit = 0; |
|||
public $offset = 0; |
|||
public $unread; |
|||
public $starred; |
|||
public $labelled; |
|||
public $annotated; |
|||
|
|||
public function __construct() { |
|||
$this->not = new ExclusionContext($this); |
|||
} |
|||
|
|||
public function __clone() { |
|||
// clone the exclusion context as well |
|||
$this->not = clone $this->not; |
|||
} |
|||
|
|||
/** @codeCoverageIgnore */ |
|||
public function __destruct() { |
|||
unset($this->not); |
|||
} |
|||
|
|||
public function limit(int $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
|
|||
public function offset(int $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
|
|||
public function unread(bool $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
|
|||
public function starred(bool $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
|
|||
public function labelled(bool $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
|
|||
public function annotated(bool $spec = null) { |
|||
return $this->act(__FUNCTION__, func_num_args(), $spec); |
|||
} |
|||
} |
File diff suppressed because it is too large
@ -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\Db; |
|||
|
|||
class ExceptionRetry extends \JKingWeb\Arsse\AbstractException { |
|||
} |
@ -0,0 +1,11 @@ |
|||
<?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\Db\SQLite3; |
|||
|
|||
abstract class AbstractPDODriver extends Driver { |
|||
use \JKingWeb\Arsse\Db\PDODriver; |
|||
} |
@ -0,0 +1,167 @@ |
|||
<?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\ImportExport; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Database; |
|||
use JKingWeb\Arsse\Db\ExceptionInput as InputException; |
|||
use JKingWeb\Arsse\User\Exception as UserException; |
|||
|
|||
abstract class AbstractImportExport { |
|||
public function import(string $user, string $data, bool $flat = false, bool $replace = false): bool { |
|||
if (!Arsse::$user->exists($user)) { |
|||
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); |
|||
} |
|||
// first extract useful information from the input |
|||
list($feeds, $folders) = $this->parse($data, $flat); |
|||
$folderMap = []; |
|||
foreach ($folders as $f) { |
|||
// check to make sure folder names are all valid |
|||
if (!strlen(trim($f['name']))) { |
|||
throw new Exception("invalidFolderName"); |
|||
} |
|||
// check for duplicates |
|||
if (!isset($folderMap[$f['parent']])) { |
|||
$folderMap[$f['parent']] = []; |
|||
} |
|||
if (isset($folderMap[$f['parent']][$f['name']])) { |
|||
throw new Exception("invalidFolderCopy"); |
|||
} else { |
|||
$folderMap[$f['parent']][$f['name']] = true; |
|||
} |
|||
} |
|||
// get feed IDs for each URL, adding feeds where necessary |
|||
foreach ($feeds as $k => $f) { |
|||
$feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); |
|||
} |
|||
// start a transaction for atomic rollback |
|||
$tr = Arsse::$db->begin(); |
|||
// get current state of database |
|||
$foldersDb = iterator_to_array(Arsse::$db->folderList($user)); |
|||
$feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); |
|||
$tagsDb = iterator_to_array(Arsse::$db->tagList($user)); |
|||
// reconcile folders |
|||
$folderMap = [0 => 0]; |
|||
foreach ($folders as $id => $f) { |
|||
$parent = $folderMap[$f['parent']]; |
|||
// find a match for the import folder in the existing folders |
|||
foreach ($foldersDb as $db) { |
|||
if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { |
|||
$folderMap[$id] = (int) $db['id']; |
|||
break; |
|||
} |
|||
} |
|||
if (!isset($folderMap[$id])) { |
|||
// if no existing folder exists, add one |
|||
$folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); |
|||
} |
|||
} |
|||
// process newsfeed subscriptions |
|||
$feedMap = []; |
|||
$tagMap = []; |
|||
foreach ($feeds as $f) { |
|||
$folder = $folderMap[$f['folder']]; |
|||
$title = strlen(trim($f['title'])) ? $f['title'] : null; |
|||
$found = false; |
|||
// find a match for the import feed is existing subscriptions |
|||
foreach ($feedsDb as $db) { |
|||
if ((int) $db['feed'] == $f['id']) { |
|||
$found = true; |
|||
$feedMap[$f['id']] = (int) $db['id']; |
|||
break; |
|||
} |
|||
} |
|||
if (!$found) { |
|||
// if no subscription exists, add one |
|||
$feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); |
|||
} |
|||
if (!$found || $replace) { |
|||
// set the subscription's properties, if this is a new feed or we're doing a full replacement |
|||
Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); |
|||
// compile the set of used tags, if this is a new feed or we're doing a full replacement |
|||
foreach ($f['tags'] as $t) { |
|||
if (!strlen(trim($t))) { |
|||
// fail if we have any blank tags |
|||
throw new Exception("invalidTagName"); |
|||
} |
|||
if (!isset($tagMap[$t])) { |
|||
// populate the tag map |
|||
$tagMap[$t] = []; |
|||
} |
|||
$tagMap[$t][] = $f['id']; |
|||
} |
|||
} |
|||
} |
|||
// set tags |
|||
$mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; |
|||
foreach ($tagMap as $tag => $subs) { |
|||
// make sure the tag exists |
|||
$found = false; |
|||
foreach ($tagsDb as $db) { |
|||
if ($tag === $db['name']) { |
|||
$found = true; |
|||
break; |
|||
} |
|||
} |
|||
if (!$found) { |
|||
// add the tag if it wasn't found |
|||
Arsse::$db->tagAdd($user, ['name' => $tag]); |
|||
} |
|||
Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); |
|||
} |
|||
// finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import |
|||
if ($replace) { |
|||
foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { |
|||
try { |
|||
Arsse::$db->subscriptionRemove($user, $id); |
|||
} catch (InputException $e) { |
|||
// ignore errors |
|||
} |
|||
} |
|||
foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { |
|||
try { |
|||
Arsse::$db->folderRemove($user, $id); |
|||
} catch (InputException $e) { |
|||
// ignore errors |
|||
} |
|||
} |
|||
foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { |
|||
try { |
|||
Arsse::$db->tagRemove($user, $id, true); |
|||
} catch (InputException $e) { |
|||
// ignore errors |
|||
} |
|||
} |
|||
} |
|||
$tr->commit(); |
|||
return true; |
|||
} |
|||
|
|||
abstract protected function parse(string $data, bool $flat): array; |
|||
|
|||
abstract public function export(string $user, bool $flat = false): string; |
|||
|
|||
public function exportFile(string $file, string $user, bool $flat = false): bool { |
|||
$data = $this->export($user, $flat); |
|||
if (!@file_put_contents($file, $data)) { |
|||
// if it fails throw an exception |
|||
$err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; |
|||
throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
public function importFile(string $file, string $user, bool $flat = false, bool $replace = false): bool { |
|||
$data = @file_get_contents($file); |
|||
if ($data === false) { |
|||
// if it fails throw an exception |
|||
$err = file_exists($file) ? "fileUnreadable" : "fileMissing"; |
|||
throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); |
|||
} |
|||
return $this->import($user, $data, $flat, $replace); |
|||
} |
|||
} |
@ -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\ImportExport; |
|||
|
|||
class Exception extends \JKingWeb\Arsse\AbstractException { |
|||
} |
@ -0,0 +1,155 @@ |
|||
<?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\ImportExport; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\User\Exception as UserException; |
|||
|
|||
class OPML extends AbstractImportExport { |
|||
protected function parse(string $opml, bool $flat): array { |
|||
$d = new \DOMDocument; |
|||
if (!@$d->loadXML($opml)) { |
|||
// not a valid XML document |
|||
$err = libxml_get_last_error(); |
|||
throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); |
|||
} |
|||
$body = (new \DOMXPath($d))->query("/opml/body"); |
|||
if ($body->length != 1) { |
|||
// not a valid OPML document |
|||
throw new Exception("invalidSemantics", ['type' => "OPML"]); |
|||
} |
|||
$body = $body->item(0); |
|||
// function to find the next node in the tree |
|||
$next = function(\DOMNode $node, bool $visitChildren = true) use ($body) { |
|||
if ($visitChildren && $node->hasChildNodes()) { |
|||
return $node->firstChild; |
|||
} elseif ($node->nextSibling) { |
|||
return $node->nextSibling; |
|||
} else { |
|||
while (!$node->nextSibling && !$node->isSameNode($body)) { |
|||
$node = $node->parentNode; |
|||
} |
|||
if (!$node->isSameNode($body)) { |
|||
return $node->nextSibling; |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
}; |
|||
$folders = []; |
|||
$feeds = []; |
|||
// add the root folder to a map from folder DOM nodes to folder ID numbers |
|||
$folderMap = new \SplObjectStorage; |
|||
$folderMap[$body] = sizeof($folderMap); |
|||
// iterate through each node in the body |
|||
$node = $body->firstChild; |
|||
while ($node) { |
|||
if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { |
|||
// process any nodes which are outlines |
|||
if ($node->getAttribute("type") === "rss") { |
|||
// feed nodes |
|||
$url = $node->getAttribute("xmlUrl"); |
|||
$title = $node->getAttribute("text"); |
|||
$folder = $folderMap[$node->parentNode] ?? 0; |
|||
$categories = $node->getAttribute("category"); |
|||
if (strlen($categories)) { |
|||
// collapse and trim whitespace from category names, if any, splitting along commas |
|||
$categories = array_map(function($v) { |
|||
return trim(preg_replace("/\s+/", " ", $v)); |
|||
}, explode(",", $categories)); |
|||
// filter out any blank categories |
|||
$categories = array_filter($categories, function($v) { |
|||
return strlen($v); |
|||
}); |
|||
} else { |
|||
$categories = []; |
|||
} |
|||
$feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'tags' => $categories]; |
|||
// skip any child nodes of a feed outline-entry |
|||
$node = $node->nextSibling ?: $node->parentNode; |
|||
} else { |
|||
// any outline entries which are not feeds are treated as folders |
|||
if (!$flat) { |
|||
// only process folders if we're not treating he file as flat |
|||
$id = sizeof($folderMap); |
|||
$folderMap[$node] = $id; |
|||
$folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; |
|||
} |
|||
// proceed to child nodes, if any |
|||
$node = $next($node); |
|||
} |
|||
} else { |
|||
// skip any node which is not an outline element; if the node has descendents they are skipped as well |
|||
$node = $next($node, false); |
|||
} |
|||
} |
|||
return [$feeds, $folders]; |
|||
} |
|||
|
|||
public function export(string $user, bool $flat = false): string { |
|||
if (!Arsse::$user->exists($user)) { |
|||
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); |
|||
} |
|||
$tags = []; |
|||
$folders = []; |
|||
$parents = [0 => null]; |
|||
// create a base document |
|||
$document = new \DOMDocument("1.0", "utf-8"); |
|||
$document->formatOutput = true; |
|||
$document->appendChild($document->createElement("opml")); |
|||
$document->documentElement->setAttribute("version", "2.0"); |
|||
$document->documentElement->appendChild($document->createElement("head")); |
|||
// create the "root folder" node (the body node, in OPML terms) |
|||
$folders[0] = $document->createElement("body"); |
|||
// begin a transaction for read isolation |
|||
$transaction = Arsse::$db->begin(); |
|||
// gather up the list of tags for each subscription |
|||
foreach (Arsse::$db->tagSummarize($user) as $r) { |
|||
$sub = $r['subscription']; |
|||
$tag = $r['name']; |
|||
// strip out any commas in the tag name; sadly this is lossy as OPML has no escape mechanism |
|||
$tag = str_replace(",", "", $tag); |
|||
if (!isset($tags[$sub])) { |
|||
$tags[$sub] = []; |
|||
} |
|||
$tags[$sub][] = $tag; |
|||
} |
|||
if (!$flat) { |
|||
// unless the output is requested flat, gather up the list of folders, using their database IDs as array indices |
|||
foreach (Arsse::$db->folderList($user) as $r) { |
|||
// note the index of its parent folder for later tree construction |
|||
$parents[$r['id']] = $r['parent'] ?? 0; |
|||
// create a DOM node for each folder; we don't insert it yet |
|||
$el = $document->createElement("outline"); |
|||
$el->setAttribute("text", $r['name']); |
|||
$folders[$r['id']] = $el; |
|||
} |
|||
} |
|||
// insert each folder into its parent node; for the root folder the parent is the document root node |
|||
foreach ($folders as $id => $el) { |
|||
$parent = $folders[$parents[$id]] ?? $document->documentElement; |
|||
$parent->appendChild($el); |
|||
} |
|||
// create a DOM node for each subscription and insert them directly into their folder DOM node |
|||
foreach (Arsse::$db->subscriptionList($user) as $r) { |
|||
$el = $document->createElement(("outline")); |
|||
$el->setAttribute("type", "rss"); |
|||
$el->setAttribute("text", $r['title']); |
|||
$el->setAttribute("xmlUrl", $r['url']); |
|||
// include the category attribute only if there are tags |
|||
if (isset($tags[$r['id']]) && sizeof($tags[$r['id']])) { |
|||
$el->setAttribute("category", implode(",", $tags[$r['id']])); |
|||
} |
|||
// if flat output was requested subscriptions are inserted into the root folder |
|||
($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); |
|||
} |
|||
// release the transaction |
|||
$transaction->rollback(); |
|||
// return the serialization |
|||
return $document->saveXML(); |
|||
} |
|||
} |
@ -0,0 +1,415 @@ |
|||
<?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\Fever; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Database; |
|||
use JKingWeb\Arsse\User; |
|||
use JKingWeb\Arsse\Service; |
|||
use JKingWeb\Arsse\Context\Context; |
|||
use JKingWeb\Arsse\Misc\ValueInfo as V; |
|||
use JKingWeb\Arsse\Misc\Date; |
|||
use JKingWeb\Arsse\AbstractException; |
|||
use JKingWeb\Arsse\Db\ExceptionInput; |
|||
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; |
|||
use Zend\Diactoros\Response\XmlResponse; |
|||
use Zend\Diactoros\Response\EmptyResponse; |
|||
|
|||
class API extends \JKingWeb\Arsse\REST\AbstractHandler { |
|||
const LEVEL = 3; |
|||
const GENERIC_ICON_TYPE = "image/png;base64"; |
|||
const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="; |
|||
|
|||
// GET parameters for which we only check presence: these will be converted to booleans |
|||
const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; |
|||
// GET parameters which contain meaningful values |
|||
const PARAM_GET = [ |
|||
'api' => V::T_STRING, // this parameter requires special handling |
|||
'page' => V::T_INT, // parameter for hot links |
|||
'range' => V::T_INT, // parameter for hot links |
|||
'offset' => V::T_INT, // parameter for hot links |
|||
'since_id' => V::T_INT, |
|||
'max_id' => V::T_INT, |
|||
'with_ids' => V::T_STRING, |
|||
'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup |
|||
'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup |
|||
]; |
|||
// POST parameters, all of which contain meaningful values |
|||
const PARAM_POST = [ |
|||
'api_key' => V::T_STRING, |
|||
'mark' => V::T_STRING, |
|||
'as' => V::T_STRING, |
|||
'id' => V::T_INT, |
|||
'before' => V::T_DATE, |
|||
'unread_recently_read' => V::T_BOOL, |
|||
]; |
|||
|
|||
public function __construct() { |
|||
} |
|||
|
|||
public function dispatch(ServerRequestInterface $req): ResponseInterface { |
|||
$G = $this->normalizeInputGet($req->getQueryParams() ?? []); |
|||
$P = $this->normalizeInputPost($req->getParsedBody() ?? []); |
|||
if (!isset($G['api'])) { |
|||
// the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 |
|||
return new EmptyResponse(404); |
|||
} |
|||
switch ($req->getMethod()) { |
|||
case "OPTIONS": |
|||
return new EmptyResponse(204, [ |
|||
'Allow' => "POST", |
|||
'Accept' => "application/x-www-form-urlencoded", |
|||
]); |
|||
case "POST": |
|||
if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { |
|||
return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); |
|||
} |
|||
$out = [ |
|||
'api_version' => self::LEVEL, |
|||
'auth' => 0, |
|||
]; |
|||
if ($req->getAttribute("authenticated", false)) { |
|||
// if HTTP authentication was successfully used, set the expected user ID |
|||
Arsse::$user->id = $req->getAttribute("authenticatedUser"); |
|||
$out['auth'] = 1; |
|||
} elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) { |
|||
// otherwise if HTTP authentication failed or is required, deny access at the HTTP level |
|||
return new EmptyResponse(401); |
|||
} |
|||
// produce a full response if authenticated or a basic response otherwise |
|||
if ($this->logIn(strtolower($P['api_key'] ?? ""))) { |
|||
$out = $this->processRequest($this->baseResponse(true), $G, $P); |
|||
} else { |
|||
$out = $this->baseResponse(false); |
|||
} |
|||
// return the result, possibly formatted as XML |
|||
return $this->formatResponse($out, ($G['api'] === "xml")); |
|||
default: |
|||
return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); |
|||
} |
|||
} |
|||
|
|||
protected function normalizeInputGet(array $data): array { |
|||
$out = []; |
|||
if (array_key_exists("api", $data)) { |
|||
// the "api" parameter must be handled specially as it a string, but null has special meaning |
|||
$data['api'] = $data['api'] ?? "json"; |
|||
} |
|||
foreach (self::PARAM_BOOL as $p) { |
|||
// first handle all the boolean parameters |
|||
$out[$p] = array_key_exists($p, $data); |
|||
} |
|||
foreach (self::PARAM_GET as $p => $t) { |
|||
$out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function normalizeInputPost(array $data): array { |
|||
$out = []; |
|||
foreach (self::PARAM_POST as $p => $t) { |
|||
$out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function processRequest(array $out, array $G, array $P): array { |
|||
$listUnread = false; |
|||
$listSaved = false; |
|||
if ($P['unread_recently_read']) { |
|||
$this->setUnread(); |
|||
$listUnread = true; |
|||
} |
|||
if ($P['mark'] && $P['as'] && is_int($P['id'])) { |
|||
// depending on which mark are being made, |
|||
// either an 'unread_item_ids' or a |
|||
// 'saved_item_ids' entry will be added later |
|||
$listSaved = $this->setMarks($P, $listUnread); |
|||
} |
|||
if ($G['feeds'] || $G['groups']) { |
|||
if ($G['groups']) { |
|||
$out['groups'] = $this->getGroups(); |
|||
} |
|||
if ($G['feeds']) { |
|||
$out['feeds'] = $this->getFeeds(); |
|||
} |
|||
$out['feeds_groups'] = $this->getRelationships(); |
|||
} |
|||
if ($G['favicons']) { |
|||
// TODO: implement favicons properly |
|||
// we provide a single blank favicon for now |
|||
$out['favicons'] = [ |
|||
[ |
|||
'id' => 0, |
|||
'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA, |
|||
], |
|||
]; |
|||
} |
|||
if ($G['items']) { |
|||
$out['items'] = $this->getItems($G); |
|||
$out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id); |
|||
} |
|||
if ($G['links']) { |
|||
// TODO: implement hot links |
|||
$out['links'] = []; |
|||
} |
|||
if ($G['unread_item_ids'] || $listUnread) { |
|||
$out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); |
|||
} |
|||
if ($G['saved_item_ids'] || $listSaved) { |
|||
$out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function baseResponse(bool $authenticated): array { |
|||
$out = [ |
|||
'api_version' => self::LEVEL, |
|||
'auth' => (int) $authenticated, |
|||
]; |
|||
if ($authenticated) { |
|||
// authenticated requests always include the most recent feed refresh |
|||
$out['last_refreshed_on_time'] = $this->getRefreshTime(); |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function formatResponse(array $data, bool $xml): ResponseInterface { |
|||
if ($xml) { |
|||
$d = new \DOMDocument("1.0", "utf-8"); |
|||
$d->appendChild($this->makeXMLAssoc($data, $d->createElement("response"))); |
|||
return new XmlResponse($d->saveXML()); |
|||
} else { |
|||
return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); |
|||
} |
|||
} |
|||
|
|||
protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement { |
|||
$d = $p->ownerDocument; |
|||
foreach ($data as $k => $v) { |
|||
if (!is_array($v)) { |
|||
$p->appendChild($d->createElement($k, (string) $v)); |
|||
} elseif (isset($v[0])) { |
|||
// this is a very simplistic check for an indexed array |
|||
// it would not pass muster in the face of generic data, |
|||
// but we'll assume our code produces only well-ordered |
|||
// indexed arrays |
|||
$p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); |
|||
} else { |
|||
$p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); |
|||
} |
|||
} |
|||
return $p; |
|||
} |
|||
|
|||
protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement { |
|||
$d = $p->ownerDocument; |
|||
foreach ($data as $v) { |
|||
if (!is_array($v)) { |
|||
// this case is never encountered with Fever's output |
|||
$p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore |
|||
} elseif (isset($v[0])) { |
|||
// this case is never encountered with Fever's output |
|||
$p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore |
|||
} else { |
|||
$p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); |
|||
} |
|||
} |
|||
return $p; |
|||
|
|||
} |
|||
|
|||
protected function logIn(string $hash): bool { |
|||
// if HTTP authentication was successful and sessions are not enforced, proceed unconditionally |
|||
if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { |
|||
return true; |
|||
} |
|||
try { |
|||
// verify the supplied hash is valid |
|||
$s = Arsse::$db->TokenLookup("fever.login", $hash); |
|||
} catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { |
|||
return false; |
|||
} |
|||
// set the user name |
|||
Arsse::$user->id = $s['user']; |
|||
return true; |
|||
} |
|||
|
|||
protected function setMarks(array $P, &$listUnread): bool { |
|||
$listSaved = false; |
|||
$c = new Context; |
|||
$id = $P['id']; |
|||
if ($P['before']) { |
|||
$c->notMarkedSince($P['before']); |
|||
} |
|||
switch ($P['mark']) { |
|||
case "item": |
|||
$c->article($id); |
|||
break; |
|||
case "group": |
|||
if ($id > 0) { |
|||
// concrete groups |
|||
$c->tag($id); |
|||
} elseif ($id < 0) { |
|||
// group negative-one is the "Sparks" supergroup i.e. no feeds |
|||
$c->not->folder(0); |
|||
} else { |
|||
// group zero is the "Kindling" supergroup i.e. all feeds |
|||
// nothing need to be done for this |
|||
} |
|||
break; |
|||
case "feed": |
|||
$c->subscription($id); |
|||
break; |
|||
default: |
|||
return $listSaved; |
|||
} |
|||
switch ($P['as']) { |
|||
case "read": |
|||
$data = ['read' => true]; |
|||
$listUnread = true; |
|||
break; |
|||
case "unread": |
|||
// this option is undocumented, but valid |
|||
$data = ['read' => false]; |
|||
$listUnread = true; |
|||
break; |
|||
case "saved": |
|||
$data = ['starred' => true]; |
|||
$listSaved = true; |
|||
break; |
|||
case "unsaved": |
|||
$data = ['starred' => false]; |
|||
$listSaved = true; |
|||
break; |
|||
default: |
|||
return $listSaved; |
|||
} |
|||
try { |
|||
Arsse::$db->articleMark(Arsse::$user->id, $data, $c); |
|||
} catch (ExceptionInput $e) { |
|||
// ignore any errors |
|||
} |
|||
return $listSaved; |
|||
} |
|||
|
|||
protected function setUnread() { |
|||
$lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue(); |
|||
if (!$lastUnread) { |
|||
// there are no articles |
|||
return; |
|||
} |
|||
// Fever takes the date of the last read article less fifteen seconds as a cut-off. |
|||
// We take the date of last mark (whether it be read, unread, saved, unsaved), which |
|||
// may not actually signify a mark, but we'll otherwise also count back fifteen seconds |
|||
$c = new Context; |
|||
$lastUnread = Date::normalize($lastUnread, "sql"); |
|||
$since = Date::sub("PT15S", $lastUnread); |
|||
$c->unread(false)->markedSince($since); |
|||
Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); |
|||
} |
|||
|
|||
protected function getRefreshTime() { |
|||
return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); |
|||
} |
|||
|
|||
protected function getFeeds(): array { |
|||
$out = []; |
|||
foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { |
|||
$out[] = [ |
|||
'id' => (int) $sub['id'], |
|||
'favicon_id' => 0, // TODO: implement favicons |
|||
'title' => (string) $sub['title'], |
|||
'url' => $sub['url'], |
|||
'site_url' => $sub['source'], |
|||
'is_spark' => 0, |
|||
'last_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), |
|||
]; |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function getGroups(): array { |
|||
$out = []; |
|||
foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) { |
|||
$out[] = [ |
|||
'id' => (int) $member['id'], |
|||
'title' => $member['name'], |
|||
]; |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function getRelationships(): array { |
|||
$out = []; |
|||
$sets = []; |
|||
foreach (Arsse::$db->tagSummarize(Arsse::$user->id) as $member) { |
|||
if (!isset($sets[$member['id']])) { |
|||
$sets[$member['id']] = []; |
|||
} |
|||
$sets[$member['id']][] = (int) $member['subscription']; |
|||
} |
|||
foreach ($sets as $id => $subs) { |
|||
$out[] = [ |
|||
'group_id' => (int) $id, |
|||
'feed_ids' => implode(",", $subs), |
|||
]; |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function getItems(array $G): array { |
|||
$c = (new Context)->limit(50); |
|||
$reverse = false; |
|||
// handle the standard options |
|||
if ($G['with_ids']) { |
|||
$c->articles(explode(",", $G['with_ids'])); |
|||
} elseif ($G['max_id']) { |
|||
$c->latestArticle($G['max_id'] - 1); |
|||
$reverse = true; |
|||
} elseif ($G['since_id']) { |
|||
$c->oldestArticle($G['since_id'] + 1); |
|||
} |
|||
// handle the undocumented options |
|||
if ($G['group_ids']) { |
|||
$c->tags(explode(",", $G['group_ids'])); |
|||
} |
|||
if ($G['feed_ids']) { |
|||
$c->subscriptions(explode(",", $G['feed_ids'])); |
|||
} |
|||
// get results |
|||
$out = []; |
|||
$order = $reverse ? "id desc" : "id"; |
|||
foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) { |
|||
$out[] = [ |
|||
'id' => (int) $r['id'], |
|||
'feed_id' => (int) $r['subscription'], |
|||
'title' => (string) $r['title'], |
|||
'author' => (string) $r['author'], |
|||
'html' => (string) $r['content'], |
|||
'url' => (string) $r['url'], |
|||
'is_saved' => (int) $r['starred'], |
|||
'is_read' => (int) !$r['unread'], |
|||
'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), |
|||
]; |
|||
} |
|||
return $out; |
|||
} |
|||
|
|||
protected function getItemIds(Context $c = null): string { |
|||
$out = []; |
|||
foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { |
|||
$out[] = (int) $r['id']; |
|||
} |
|||
return implode(",", $out); |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
<?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\Fever; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Db\ExceptionInput; |
|||
|
|||
class User { |
|||
public function register(string $user, string $password = null): string { |
|||
$password = $password ?? Arsse::$user->generatePassword(); |
|||
$hash = md5("$user:$password"); |
|||
$tr = Arsse::$db->begin(); |
|||
Arsse::$db->tokenRevoke($user, "fever.login"); |
|||
Arsse::$db->tokenCreate($user, "fever.login", $hash); |
|||
$tr->commit(); |
|||
return $password; |
|||
} |
|||
|
|||
public function unregister(string $user): bool { |
|||
return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); |
|||
} |
|||
|
|||
public function authenticate(string $user, string $password): bool { |
|||
try { |
|||
return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); |
|||
} catch (ExceptionInput $e) { |
|||
return false; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,367 @@ |
|||
<?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\TinyTinyRSS; |
|||
|
|||
use JKingWeb\Arsse\Context\Context; |
|||
use JKingWeb\Arsse\Misc\Date; |
|||
|
|||
class Search { |
|||
const STATE_BEFORE_TOKEN = 0; |
|||
const STATE_BEFORE_TOKEN_QUOTED = 1; |
|||
const STATE_IN_DATE = 2; |
|||
const STATE_IN_DATE_QUOTED = 3; |
|||
const STATE_IN_TOKEN_OR_TAG = 4; |
|||
const STATE_IN_TOKEN_OR_TAG_QUOTED = 5; |
|||
const STATE_IN_TOKEN = 6; |
|||
const STATE_IN_TOKEN_QUOTED = 7; |
|||
|
|||
const FIELDS_BOOLEAN = [ |
|||
"unread" => "unread", |
|||
"star" => "starred", |
|||
"note" => "annotated", |
|||
"pub" => "published", // TODO: not implemented |
|||
]; |
|||
const FIELDS_TEXT = [ |
|||
"title" => "titleTerms", |
|||
"author" => "authorTerms", |
|||
"note" => "annotationTerms", |
|||
"" => "searchTerms", |
|||
]; |
|||
|
|||
public static function parse(string $search, Context $context = null) { |
|||
// normalize the input |
|||
$search = strtolower(trim(preg_replace("<\s+>", " ", $search))); |
|||
// set initial state |
|||
$tokens = []; |
|||
$pos = -1; |
|||
$stop = strlen($search); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$buffer = ""; |
|||
$tag = ""; |
|||
$flag_negative = false; |
|||
$context = $context ?? new Context; |
|||
// process |
|||
try { |
|||
while (++$pos <= $stop) { |
|||
$char = @$search[$pos]; |
|||
switch ($state) { |
|||
case self::STATE_BEFORE_TOKEN: |
|||
switch ($char) { |
|||
case "": |
|||
continue 3; |
|||
case " ": |
|||
continue 3; |
|||
case '"': |
|||
if ($flag_negative) { |
|||
$buffer .= $char; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG; |
|||
} else { |
|||
$state = self::STATE_BEFORE_TOKEN_QUOTED; |
|||
} |
|||
continue 3; |
|||
case "-": |
|||
if (!$flag_negative) { |
|||
$flag_negative = true; |
|||
} else { |
|||
$buffer .= $char; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG; |
|||
} |
|||
continue 3; |
|||
case "@": |
|||
$state = self::STATE_IN_DATE; |
|||
continue 3; |
|||
case ":": |
|||
$state = self::STATE_IN_TOKEN; |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
case self::STATE_BEFORE_TOKEN_QUOTED: |
|||
switch ($char) { |
|||
case "": |
|||
continue 3; |
|||
case '"': |
|||
if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; |
|||
} else { |
|||
$state = self::STATE_IN_TOKEN_OR_TAG; |
|||
} |
|||
continue 3; |
|||
case "\\": |
|||
if ($pos + 1 == $stop) { |
|||
$buffer .= $char; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$buffer .= $char; |
|||
} |
|||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; |
|||
continue 3; |
|||
case "-": |
|||
if (!$flag_negative) { |
|||
$flag_negative = true; |
|||
} else { |
|||
$buffer .= $char; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; |
|||
} |
|||
continue 3; |
|||
case "@": |
|||
$state = self::STATE_IN_DATE_QUOTED; |
|||
continue 3; |
|||
case ":": |
|||
$state = self::STATE_IN_TOKEN_QUOTED; |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
case self::STATE_IN_DATE: |
|||
while ($pos < $stop && $search[$pos] !== " ") { |
|||
$buffer .= $search[$pos++]; |
|||
} |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, true); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
continue 2; |
|||
case self::STATE_IN_DATE_QUOTED: |
|||
switch ($char) { |
|||
case "": |
|||
case '"': |
|||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, true); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$state = self::STATE_IN_DATE; |
|||
} |
|||
continue 3; |
|||
case "\\": |
|||
if ($pos + 1 == $stop) { |
|||
$buffer .= $char; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$buffer .= $char; |
|||
} |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
case self::STATE_IN_TOKEN: |
|||
while ($pos < $stop && $search[$pos] !== " ") { |
|||
$buffer .= $search[$pos++]; |
|||
} |
|||
if (!strlen($tag)) { |
|||
$buffer = ":".$buffer; |
|||
} |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
continue 2; |
|||
case self::STATE_IN_TOKEN_QUOTED: |
|||
switch ($char) { |
|||
case "": |
|||
case '"': |
|||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { |
|||
if (!strlen($tag)) { |
|||
$buffer = ":".$buffer; |
|||
} |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$state = self::STATE_IN_TOKEN; |
|||
} |
|||
continue 3; |
|||
case "\\": |
|||
if ($pos + 1 == $stop) { |
|||
$buffer .= $char; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$buffer .= $char; |
|||
} |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
case self::STATE_IN_TOKEN_OR_TAG: |
|||
switch ($char) { |
|||
case "": |
|||
case " ": |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
continue 3; |
|||
case ":": |
|||
$tag = $buffer; |
|||
$buffer = ""; |
|||
$state = self::STATE_IN_TOKEN; |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
case self::STATE_IN_TOKEN_OR_TAG_QUOTED: |
|||
switch ($char) { |
|||
case "": |
|||
case '"': |
|||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { |
|||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false); |
|||
$state = self::STATE_BEFORE_TOKEN; |
|||
$flag_negative = false; |
|||
$buffer = $tag = ""; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$state = self::STATE_IN_TOKEN_OR_TAG; |
|||
} |
|||
continue 3; |
|||
case "\\": |
|||
if ($pos + 1 == $stop) { |
|||
$buffer .= $char; |
|||
} elseif ($search[$pos + 1] === '"') { |
|||
$buffer .= '"'; |
|||
$pos++; |
|||
} else { |
|||
$buffer .= $char; |
|||
} |
|||
continue 3; |
|||
case ":": |
|||
$tag = $buffer; |
|||
$buffer = ""; |
|||
$state = self::STATE_IN_TOKEN_QUOTED; |
|||
continue 3; |
|||
default: |
|||
$buffer .= $char; |
|||
continue 3; |
|||
} |
|||
// no break |
|||
default: |
|||
throw new \Exception; // @codeCoverageIgnore |
|||
} |
|||
} |
|||
} catch (Exception $e) { |
|||
return null; |
|||
} |
|||
return $context; |
|||
} |
|||
|
|||
protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { |
|||
if (!strlen($value) && !strlen($tag)) { |
|||
return $c; |
|||
} elseif (!strlen($value)) { |
|||
// if a tag has an empty value, the tag is treated as a search term instead |
|||
$value = "$tag:"; |
|||
$tag = ""; |
|||
} |
|||
if ($date) { |
|||
return self::setDate($value, $c, $neg); |
|||
} elseif (isset(self::FIELDS_BOOLEAN[$tag])) { |
|||
return self::setBoolean($tag, $value, $c, $neg); |
|||
} else { |
|||
return self::addTerm($tag, $value, $c, $neg); |
|||
} |
|||
} |
|||
|
|||
protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context { |
|||
$c = $neg ? $c->not : $c; |
|||
$type = self::FIELDS_TEXT[$tag] ?? ""; |
|||
if (!$type) { |
|||
$value = "$tag:$value"; |
|||
$type = self::FIELDS_TEXT[""]; |
|||
} |
|||
return $c->$type(array_merge($c->$type ?? [], [$value])); |
|||
} |
|||
|
|||
protected static function setDate(string $value, Context $c, bool $neg): Context { |
|||
$spec = Date::normalize($value); |
|||
// TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead |
|||
if (!$spec) { |
|||
return $c; |
|||
} |
|||
$day = $spec->format("Y-m-d"); |
|||
$start = $day."T00:00:00+00:00"; |
|||
$end = $day."T23:59:59+00:00"; |
|||
// if a date is already set, the same date is a no-op; anything else is a contradiction |
|||
$cc = $neg ? $c->not : $c; |
|||
if ($cc->modifiedSince() || $cc->notModifiedSince()) { |
|||
if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) { |
|||
// FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this |
|||
throw new Exception; |
|||
} else { |
|||
return $c; |
|||
} |
|||
} |
|||
$cc->modifiedSince($start); |
|||
$cc->notModifiedSince($end); |
|||
return $c; |
|||
} |
|||
|
|||
protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { |
|||
$set = ["true" => true, "false" => false][$value] ?? null; |
|||
if (is_null($set)) { |
|||
return self::addTerm($tag, $value, $c, $neg); |
|||
} else { |
|||
// apply negation |
|||
$set = $neg ? !$set : $set; |
|||
if ($tag === "pub") { |
|||
// TODO: this needs to be implemented correctly if the Published feed is implemented |
|||
// currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything) |
|||
if ($set) { |
|||
throw new Exception; |
|||
} else { |
|||
return $c; |
|||
} |
|||
} else { |
|||
$field = (self::FIELDS_BOOLEAN[$tag] ?? ""); |
|||
if (!$c->$field()) { |
|||
// field has not yet been set; set it |
|||
return $c->$field($set); |
|||
} elseif ($c->$field == $set) { |
|||
// field is already set to same value; do nothing |
|||
return $c; |
|||
} else { |
|||
// contradiction: query would return no results |
|||
throw new Exception; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
@ -1,10 +1,11 @@ |
|||
#! /bin/sh |
|||
base=`dirname "$0"` |
|||
roboCommand="$1" |
|||
|
|||
shift |
|||
if [ "$1" == "clean" ]; then |
|||
"$base/vendor/bin/robo" "$roboCommand" $* |
|||
|
|||
ulimit -n 2048 |
|||
if [ "$1" = "clean" ]; then |
|||
"$base/vendor/bin/robo" "$roboCommand" "$@" |
|||
else |
|||
"$base/vendor/bin/robo" "$roboCommand" -- $* |
|||
fi |
|||
"$base/vendor/bin/robo" "$roboCommand" -- "$@" |
|||
fi |
|||
|
@ -0,0 +1,41 @@ |
|||
-- SPDX-License-Identifier: MIT |
|||
-- Copyright 2017 J. King, Dustin Wilson et al. |
|||
-- See LICENSE and AUTHORS files for details |
|||
|
|||
-- Please consult the SQLite 3 schemata for commented version |
|||
|
|||
create table arsse_tags( |
|||
id serial primary key, |
|||
owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, |
|||
name varchar(255) not null, |
|||
modified datetime(0) not null default CURRENT_TIMESTAMP, |
|||
unique(owner,name) |
|||
) character set utf8mb4 collate utf8mb4_unicode_ci; |
|||
|
|||
create table arsse_tag_members( |
|||
tag bigint not null references arsse_tags(id) on delete cascade, |
|||
subscription bigint not null references arsse_subscriptions(id) on delete cascade, |
|||
assigned boolean not null default 1, |
|||
modified datetime(0) not null default CURRENT_TIMESTAMP, |
|||
primary key(tag,subscription) |
|||
) character set utf8mb4 collate utf8mb4_unicode_ci; |
|||
|
|||
create table arsse_tokens( |
|||
id varchar(255) not null, |
|||
class varchar(255) not null, |
|||
"user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade, |
|||
created datetime(0) not null default CURRENT_TIMESTAMP, |
|||
expires datetime(0), |
|||
primary key(id,class) |
|||
) character set utf8mb4 collate utf8mb4_unicode_ci; |
|||
|
|||
alter table arsse_users drop column name; |
|||
alter table arsse_users drop column avatar_type; |
|||
alter table arsse_users drop column avatar_data; |
|||
alter table arsse_users drop column admin; |
|||
alter table arsse_users drop column rights; |
|||
|
|||
drop table arsse_users_meta; |
|||
|
|||
|
|||
update arsse_meta set value = '5' where "key" = 'schema_version'; |
@ -0,0 +1,40 @@ |
|||
-- SPDX-License-Identifier: MIT |
|||
-- Copyright 2017 J. King, Dustin Wilson et al. |
|||
-- See LICENSE and AUTHORS files for details |
|||
|
|||
-- Please consult the SQLite 3 schemata for commented version |
|||
|
|||
create table arsse_tags( |
|||
id bigserial primary key, |
|||
owner text not null references arsse_users(id) on delete cascade on update cascade, |
|||
name text not null collate "und-x-icu", |
|||
modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, |
|||
unique(owner,name) |
|||
); |
|||
|
|||
create table arsse_tag_members( |
|||
tag bigint not null references arsse_tags(id) on delete cascade, |
|||
subscription bigint not null references arsse_subscriptions(id) on delete cascade, |
|||
assigned smallint not null default 1, |
|||
modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, |
|||
primary key(tag,subscription) |
|||
); |
|||
|
|||
create table arsse_tokens( |
|||
id text, |
|||
class text not null, |
|||
"user" text not null references arsse_users(id) on delete cascade on update cascade, |
|||
created timestamp(0) without time zone not null default CURRENT_TIMESTAMP, |
|||
expires timestamp(0) without time zone, |
|||
primary key(id,class) |
|||
); |
|||
|
|||
alter table arsse_users drop column name; |
|||
alter table arsse_users drop column avatar_type; |
|||
alter table arsse_users drop column avatar_data; |
|||
alter table arsse_users drop column admin; |
|||
alter table arsse_users drop column rights; |
|||
|
|||
drop table arsse_users_meta; |
|||
|
|||
update arsse_meta set value = '5' where "key" = 'schema_version'; |
@ -0,0 +1,78 @@ |
|||
-- SPDX-License-Identifier: MIT |
|||
-- Copyright 2017 J. King, Dustin Wilson et al. |
|||
-- See LICENSE and AUTHORS files for details |
|||
|
|||
create table arsse_tags( |
|||
-- user-defined subscription tags |
|||
id integer primary key, -- numeric ID |
|||
owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user |
|||
name text not null collate nocase, -- tag text |
|||
modified text not null default CURRENT_TIMESTAMP, -- time at which the tag was last modified |
|||
unique(owner,name) |
|||
); |
|||
|
|||
create table arsse_tag_members( |
|||
-- tag assignments for subscriptions |
|||
tag integer not null references arsse_tags(id) on delete cascade, -- tag ID associated to a subscription |
|||
subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription associated to a tag |
|||
assigned boolean not null default 1, -- whether the association is current, to support soft deletion |
|||
modified text not null default CURRENT_TIMESTAMP, -- time at which the association was last made or unmade |
|||
primary key(tag,subscription) -- only one association of a given tag to a given subscription |
|||
) without rowid; |
|||
|
|||
create table arsse_tokens( |
|||
-- access tokens that are managed by the protocol handler and may optionally expire |
|||
id text, -- token identifier |
|||
class text not null, -- symbolic name of the protocol handler managing the token |
|||
user text not null references arsse_users(id) on delete cascade on update cascade, -- user associated with the token |
|||
created text not null default CURRENT_TIMESTAMP, -- creation timestamp |
|||
expires text, -- time at which token is no longer valid |
|||
primary key(id,class) -- tokens must be unique for their class |
|||
) without rowid; |
|||
|
|||
|
|||
-- clean up the user tables to remove unused stuff |
|||
-- if any of the removed things are implemented in future, necessary structures will be added back in at that time |
|||
|
|||
create table arsse_users_new( |
|||
-- users |
|||
id text primary key not null collate nocase, -- user id |
|||
password text -- password, salted and hashed; if using external authentication this would be blank |
|||
) without rowid; |
|||
insert into arsse_users_new select id,password from arsse_users; |
|||
drop table arsse_users; |
|||
alter table arsse_users_new rename to arsse_users; |
|||
|
|||
drop table arsse_users_meta; |
|||
|
|||
|
|||
-- use WITHOUT ROWID tables when possible; this is an SQLite-specific change |
|||
|
|||
create table arsse_meta_new( |
|||
-- application metadata |
|||
key text primary key not null, -- metadata key |
|||
value text -- metadata value, serialized as a string |
|||
) without rowid; |
|||
insert into arsse_meta_new select * from arsse_meta; |
|||
drop table arsse_meta; |
|||
alter table arsse_meta_new rename to arsse_meta; |
|||
|
|||
create table arsse_marks_new( |
|||
-- users' actions on newsfeed entries |
|||
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks |
|||
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user |
|||
read boolean not null default 0, -- whether the article has been read |
|||
starred boolean not null default 0, -- whether the article is starred |
|||
modified text, -- time at which an article was last modified by a given user |
|||
note text not null default '', -- Tiny Tiny RSS freeform user note |
|||
touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions |
|||
primary key(article,subscription) -- no more than one mark-set per article per user |
|||
) without rowid; |
|||
insert into arsse_marks_new select * from arsse_marks; |
|||
drop table arsse_marks; |
|||
alter table arsse_marks_new rename to arsse_marks; |
|||
|
|||
|
|||
-- set version marker |
|||
pragma user_version = 5; |
|||
update arsse_meta set value = '5' where "key" = 'schema_version'; |
@ -0,0 +1,425 @@ |
|||
<?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\Database; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Database; |
|||
use JKingWeb\Arsse\Misc\Date; |
|||
use Phake; |
|||
|
|||
trait SeriesTag { |
|||
protected function setUpSeriesTag() { |
|||
$this->data = [ |
|||
'arsse_users' => [ |
|||
'columns' => [ |
|||
'id' => 'str', |
|||
'password' => 'str', |
|||
], |
|||
'rows' => [ |
|||
["jane.doe@example.com", ""], |
|||
["john.doe@example.com", ""], |
|||
["john.doe@example.org", ""], |
|||
["john.doe@example.net", ""], |
|||
], |
|||
], |
|||
'arsse_feeds' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'url' => "str", |
|||
'title' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1,"http://example.com/1",""], |
|||
[2,"http://example.com/2",""], |
|||
[3,"http://example.com/3","Feed Title"], |
|||
[4,"http://example.com/4",""], |
|||
[5,"http://example.com/5","Feed Title"], |
|||
[6,"http://example.com/6",""], |
|||
[7,"http://example.com/7",""], |
|||
[8,"http://example.com/8",""], |
|||
[9,"http://example.com/9",""], |
|||
[10,"http://example.com/10",""], |
|||
[11,"http://example.com/11",""], |
|||
[12,"http://example.com/12",""], |
|||
[13,"http://example.com/13",""], |
|||
] |
|||
], |
|||
'arsse_subscriptions' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'owner' => "str", |
|||
'feed' => "int", |
|||
'title' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1, "john.doe@example.com", 1,"Lord of Carrots"], |
|||
[2, "john.doe@example.com", 2,null], |
|||
[3, "john.doe@example.com", 3,"Subscription Title"], |
|||
[4, "john.doe@example.com", 4,null], |
|||
[5, "john.doe@example.com",10,null], |
|||
[6, "jane.doe@example.com", 1,null], |
|||
[7, "jane.doe@example.com",10,null], |
|||
[8, "john.doe@example.org",11,null], |
|||
[9, "john.doe@example.org",12,null], |
|||
[10,"john.doe@example.org",13,null], |
|||
[11,"john.doe@example.net",10,null], |
|||
[12,"john.doe@example.net", 2,null], |
|||
[13,"john.doe@example.net", 3,null], |
|||
[14,"john.doe@example.net", 4,null], |
|||
] |
|||
], |
|||
'arsse_tags' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'owner' => "str", |
|||
'name' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1,"john.doe@example.com","Interesting"], |
|||
[2,"john.doe@example.com","Fascinating"], |
|||
[3,"jane.doe@example.com","Boring"], |
|||
[4,"john.doe@example.com","Lonely"], |
|||
], |
|||
], |
|||
'arsse_tag_members' => [ |
|||
'columns' => [ |
|||
'tag' => "int", |
|||
'subscription' => "int", |
|||
'assigned' => "bool", |
|||
], |
|||
'rows' => [ |
|||
[1,1,1], |
|||
[1,3,0], |
|||
[1,5,1], |
|||
[2,1,1], |
|||
[2,3,1], |
|||
[2,5,1], |
|||
], |
|||
], |
|||
]; |
|||
$this->checkTags = ['arsse_tags' => ["id","owner","name"]]; |
|||
$this->checkMembers = ['arsse_tag_members' => ["tag","subscription","assigned"]]; |
|||
$this->user = "john.doe@example.com"; |
|||
} |
|||
|
|||
protected function tearDownSeriesTag() { |
|||
unset($this->data, $this->checkTags, $this->checkMembers, $this->user); |
|||
} |
|||
|
|||
public function testAddATag() { |
|||
$user = "john.doe@example.com"; |
|||
$tagID = $this->nextID("arsse_tags"); |
|||
$this->assertSame($tagID, Arsse::$db->tagAdd($user, ['name' => "Entertaining"])); |
|||
Phake::verify(Arsse::$user)->authorize($user, "tagAdd"); |
|||
$state = $this->primeExpectations($this->data, $this->checkTags); |
|||
$state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testAddADuplicateTag() { |
|||
$this->assertException("constraintViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Interesting"]); |
|||
} |
|||
|
|||
public function testAddATagWithAMissingName() { |
|||
$this->assertException("missing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagAdd("john.doe@example.com", []); |
|||
} |
|||
|
|||
public function testAddATagWithABlankName() { |
|||
$this->assertException("missing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagAdd("john.doe@example.com", ['name' => ""]); |
|||
} |
|||
|
|||
public function testAddATagWithAWhitespaceName() { |
|||
$this->assertException("whitespace", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]); |
|||
} |
|||
|
|||
public function testAddATagWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]); |
|||
} |
|||
|
|||
public function testListTags() { |
|||
$exp = [ |
|||
['id' => 2, 'name' => "Fascinating"], |
|||
['id' => 1, 'name' => "Interesting"], |
|||
['id' => 4, 'name' => "Lonely"], |
|||
]; |
|||
$this->assertResult($exp, Arsse::$db->tagList("john.doe@example.com")); |
|||
$exp = [ |
|||
['id' => 3, 'name' => "Boring"], |
|||
]; |
|||
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com")); |
|||
$exp = []; |
|||
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com", false)); |
|||
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagList"); |
|||
} |
|||
|
|||
public function testListTagsWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagList("john.doe@example.com"); |
|||
} |
|||
|
|||
public function testRemoveATag() { |
|||
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", 1)); |
|||
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); |
|||
$state = $this->primeExpectations($this->data, $this->checkTags); |
|||
array_shift($state['arsse_tags']['rows']); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testRemoveATagByName() { |
|||
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", "Interesting", true)); |
|||
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); |
|||
$state = $this->primeExpectations($this->data, $this->checkTags); |
|||
array_shift($state['arsse_tags']['rows']); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testRemoveAMissingTag() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagRemove("john.doe@example.com", 2112); |
|||
} |
|||
|
|||
public function testRemoveAnInvalidTag() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagRemove("john.doe@example.com", -1); |
|||
} |
|||
|
|||
public function testRemoveAnInvalidTagByName() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagRemove("john.doe@example.com", [], true); |
|||
} |
|||
|
|||
public function testRemoveATagOfTheWrongOwner() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane |
|||
} |
|||
|
|||
public function testRemoveATagWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagRemove("john.doe@example.com", 1); |
|||
} |
|||
|
|||
public function testGetThePropertiesOfATag() { |
|||
$exp = [ |
|||
'id' => 2, |
|||
'name' => "Fascinating", |
|||
]; |
|||
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", 2)); |
|||
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", "Fascinating", true)); |
|||
Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "tagPropertiesGet"); |
|||
} |
|||
|
|||
public function testGetThePropertiesOfAMissingTag() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesGet("john.doe@example.com", 2112); |
|||
} |
|||
|
|||
public function testGetThePropertiesOfAnInvalidTag() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesGet("john.doe@example.com", -1); |
|||
} |
|||
|
|||
public function testGetThePropertiesOfAnInvalidTagByName() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesGet("john.doe@example.com", [], true); |
|||
} |
|||
|
|||
public function testGetThePropertiesOfATagOfTheWrongOwner() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane |
|||
} |
|||
|
|||
public function testGetThePropertiesOfATagWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagPropertiesGet("john.doe@example.com", 1); |
|||
} |
|||
|
|||
public function testMakeNoChangesToATag() { |
|||
$this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, [])); |
|||
} |
|||
|
|||
public function testRenameATag() { |
|||
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"])); |
|||
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); |
|||
$state = $this->primeExpectations($this->data, $this->checkTags); |
|||
$state['arsse_tags']['rows'][0][2] = "Curious"; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testRenameATagByName() { |
|||
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true)); |
|||
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); |
|||
$state = $this->primeExpectations($this->data, $this->checkTags); |
|||
$state['arsse_tags']['rows'][0][2] = "Curious"; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testRenameATagToTheEmptyString() { |
|||
$this->assertException("missing", "Db", "ExceptionInput"); |
|||
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => ""])); |
|||
} |
|||
|
|||
public function testRenameATagToWhitespaceOnly() { |
|||
$this->assertException("whitespace", "Db", "ExceptionInput"); |
|||
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => " "])); |
|||
} |
|||
|
|||
public function testRenameATagToAnInvalidValue() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => []])); |
|||
} |
|||
|
|||
public function testCauseATagCollision() { |
|||
$this->assertException("constraintViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]); |
|||
} |
|||
|
|||
public function testSetThePropertiesOfAMissingTag() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]); |
|||
} |
|||
|
|||
public function testSetThePropertiesOfAnInvalidTag() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]); |
|||
} |
|||
|
|||
public function testSetThePropertiesOfAnInvalidTagByName() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true); |
|||
} |
|||
|
|||
public function testSetThePropertiesOfATagForTheWrongOwner() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane |
|||
} |
|||
|
|||
public function testSetThePropertiesOfATagWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); |
|||
} |
|||
|
|||
public function testListTaggedSubscriptions() { |
|||
$exp = [1,5]; |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); |
|||
$exp = [1,3,5]; |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 2)); |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Fascinating", true)); |
|||
$exp = []; |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 4)); |
|||
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); |
|||
} |
|||
|
|||
public function testListTaggedSubscriptionsForAMissingTag() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); |
|||
} |
|||
|
|||
public function testListTaggedSubscriptionsForAnInvalidTag() { |
|||
$this->assertException("typeViolation", "Db", "ExceptionInput"); |
|||
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); |
|||
} |
|||
|
|||
public function testListTaggedSubscriptionsWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); |
|||
} |
|||
|
|||
public function testApplyATagToSubscriptions() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][1][2] = 1; |
|||
$state['arsse_tag_members']['rows'][] = [1,4,1]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testClearATagFromSubscriptions() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][0][2] = 0; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testApplyATagToSubscriptionsByName() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], Database::ASSOC_ADD, true); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][1][2] = 1; |
|||
$state['arsse_tag_members']['rows'][] = [1,4,1]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testClearATagFromSubscriptionsByName() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][0][2] = 0; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testApplyATagToNoSubscriptionsByName() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testClearATagFromNoSubscriptionsByName() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testReplaceSubscriptionsOfATag() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4], Database::ASSOC_REPLACE); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][0][2] = 0; |
|||
$state['arsse_tag_members']['rows'][1][2] = 1; |
|||
$state['arsse_tag_members']['rows'][2][2] = 0; |
|||
$state['arsse_tag_members']['rows'][] = [1,4,1]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testPurgeSubscriptionsOfATag() { |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [], Database::ASSOC_REPLACE); |
|||
$state = $this->primeExpectations($this->data, $this->checkMembers); |
|||
$state['arsse_tag_members']['rows'][0][2] = 0; |
|||
$state['arsse_tag_members']['rows'][2][2] = 0; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testApplyATagToSubscriptionsWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); |
|||
} |
|||
|
|||
public function testSummarizeTags() { |
|||
$exp = [ |
|||
['id' => 1, 'name' => "Interesting", 'subscription' => 1], |
|||
['id' => 1, 'name' => "Interesting", 'subscription' => 5], |
|||
['id' => 2, 'name' => "Fascinating", 'subscription' => 1], |
|||
['id' => 2, 'name' => "Fascinating", 'subscription' => 3], |
|||
['id' => 2, 'name' => "Fascinating", 'subscription' => 5], |
|||
]; |
|||
$this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); |
|||
} |
|||
|
|||
public function testSummarizeTagsWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tagSummarize("john.doe@example.com"); |
|||
} |
|||
} |
@ -0,0 +1,140 @@ |
|||
<?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\Database; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Misc\Date; |
|||
use Phake; |
|||
|
|||
trait SeriesToken { |
|||
protected function setUpSeriesToken() { |
|||
// set up the test data |
|||
$past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute")); |
|||
$future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute")); |
|||
$faroff = gmdate("Y-m-d H:i:s", strtotime("now + 1 hour")); |
|||
$old = gmdate("Y-m-d H:i:s", strtotime("now - 2 days")); |
|||
$this->data = [ |
|||
'arsse_users' => [ |
|||
'columns' => [ |
|||
'id' => 'str', |
|||
'password' => 'str', |
|||
], |
|||
'rows' => [ |
|||
["jane.doe@example.com", ""], |
|||
["john.doe@example.com", ""], |
|||
], |
|||
], |
|||
'arsse_tokens' => [ |
|||
'columns' => [ |
|||
'id' => "str", |
|||
'class' => "str", |
|||
'user' => "str", |
|||
'expires' => "datetime", |
|||
], |
|||
'rows' => [ |
|||
["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], |
|||
["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired |
|||
["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], |
|||
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future], |
|||
], |
|||
], |
|||
]; |
|||
} |
|||
|
|||
protected function tearDownSeriesToken() { |
|||
unset($this->data); |
|||
} |
|||
|
|||
public function testLookUpAValidToken() { |
|||
$exp1 = [ |
|||
'id' => "80fa94c1a11f11e78667001e673b2560", |
|||
'class' => "fever.login", |
|||
'user' => "jane.doe@example.com" |
|||
]; |
|||
$exp2 = [ |
|||
'id' => "da772f8fa13c11e78667001e673b2560", |
|||
'class' => "class.class", |
|||
'user' => "john.doe@example.com" |
|||
]; |
|||
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); |
|||
$this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560")); |
|||
// token lookup should not check authorization |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); |
|||
} |
|||
|
|||
public function testLookUpAMissingToken() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tokenLookup("class", "thisTokenDoesNotExist"); |
|||
} |
|||
|
|||
public function testLookUpAnExpiredToken() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tokenLookup("fever.login", "27c6de8da13311e78667001e673b2560"); |
|||
} |
|||
|
|||
public function testLookUpATokenOfTheWrongClass() { |
|||
$this->assertException("subjectMissing", "Db", "ExceptionInput"); |
|||
Arsse::$db->tokenLookup("some.class", "80fa94c1a11f11e78667001e673b2560"); |
|||
} |
|||
|
|||
public function testCreateAToken() { |
|||
$user = "jane.doe@example.com"; |
|||
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); |
|||
$id = Arsse::$db->tokenCreate($user, "fever.login"); |
|||
$state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
$id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); |
|||
$state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); |
|||
$state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; |
|||
$this->compareExpectations(static::$drv, $state); |
|||
} |
|||
|
|||
public function testCreateATokenForAMissingUser() { |
|||
$this->assertException("doesNotExist", "User"); |
|||
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz"); |
|||
} |
|||
|
|||
public function testCreateATokenWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com"); |
|||
} |
|||
|
|||
public function testRevokeAToken() { |
|||
$user = "jane.doe@example.com"; |
|||
$id = "80fa94c1a11f11e78667001e673b2560"; |
|||
$this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); |
|||
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); |
|||
unset($state['arsse_tokens']['rows'][0]); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
// revoking a token which does not exist is not an error |
|||
$this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); |
|||
} |
|||
|
|||
public function testRevokeAllTokens() { |
|||
$user = "jane.doe@example.com"; |
|||
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); |
|||
$this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); |
|||
unset($state['arsse_tokens']['rows'][0]); |
|||
unset($state['arsse_tokens']['rows'][1]); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
$this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); |
|||
unset($state['arsse_tokens']['rows'][2]); |
|||
$this->compareExpectations(static::$drv, $state); |
|||
// revoking tokens which do not exist is not an error |
|||
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); |
|||
} |
|||
|
|||
public function testRevokeATokenWithoutAuthority() { |
|||
Phake::when(Arsse::$user)->authorize->thenReturn(false); |
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); |
|||
Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login"); |
|||
} |
|||
} |
@ -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\TestCase\ImportExport; |
|||
|
|||
use JKingWeb\Arsse\ImportExport\AbstractImportExport; |
|||
use JKingWeb\Arsse\ImportExport\Exception; |
|||
use org\bovigo\vfs\vfsStream; |
|||
|
|||
/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */ |
|||
class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
protected $vfs; |
|||
protected $path; |
|||
protected $proc; |
|||
|
|||
public function setUp() { |
|||
self::clearData(); |
|||
// create a mock Import/Export processor with stubbed underlying import/export routines |
|||
$this->proc = \Phake::partialMock(AbstractImportExport::class); |
|||
\Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); |
|||
\Phake::when($this->proc)->import->thenReturn(true); |
|||
$this->vfs = vfsStream::setup("root", null, [ |
|||
'exportGoodFile' => "", |
|||
'exportGoodDir' => [], |
|||
'exportBadFile' => "", |
|||
'exportBadDir' => [], |
|||
'importGoodFile' => "GOOD_FILE", |
|||
'importBadFile' => "", |
|||
]); |
|||
$this->path = $this->vfs->url()."/"; |
|||
// make the "bad" entries inaccessible |
|||
chmod($this->path."exportBadFile", 0000); |
|||
chmod($this->path."exportBadDir", 0000); |
|||
chmod($this->path."importBadFile", 0000); |
|||
} |
|||
|
|||
public function tearDown() { |
|||
$this->path = null; |
|||
$this->vfs = null; |
|||
$this->proc = null; |
|||
self::clearData(); |
|||
} |
|||
|
|||
/** @dataProvider provideFileExports */ |
|||
public function testExportToAFile(string $file, string $user, bool $flat, $exp) { |
|||
$path = $this->path.$file; |
|||
try { |
|||
if ($exp instanceof \JKingWeb\Arsse\AbstractException) { |
|||
$this->assertException($exp); |
|||
$this->proc->exportFile($path, $user, $flat); |
|||
} else { |
|||
$this->assertSame($exp, $this->proc->exportFile($path, $user, $flat)); |
|||
$this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent()); |
|||
} |
|||
} finally { |
|||
\Phake::verify($this->proc)->export($user, $flat); |
|||
} |
|||
} |
|||
|
|||
public function provideFileExports() { |
|||
$createException = new Exception("fileUncreatable"); |
|||
$writeException = new Exception("fileUnwritable"); |
|||
return [ |
|||
["exportGoodFile", "john.doe@example.com", true, true], |
|||
["exportGoodFile", "john.doe@example.com", false, true], |
|||
["exportGoodFile", "jane.doe@example.com", true, true], |
|||
["exportGoodFile", "jane.doe@example.com", false, true], |
|||
["exportGoodDir/file", "john.doe@example.com", true, true], |
|||
["exportGoodDir/file", "john.doe@example.com", false, true], |
|||
["exportGoodDir/file", "jane.doe@example.com", true, true], |
|||
["exportGoodDir/file", "jane.doe@example.com", false, true], |
|||
["exportBadFile", "john.doe@example.com", true, $writeException], |
|||
["exportBadFile", "john.doe@example.com", false, $writeException], |
|||
["exportBadFile", "jane.doe@example.com", true, $writeException], |
|||
["exportBadFile", "jane.doe@example.com", false, $writeException], |
|||
["exportBadDir/file", "john.doe@example.com", true, $createException], |
|||
["exportBadDir/file", "john.doe@example.com", false, $createException], |
|||
["exportBadDir/file", "jane.doe@example.com", true, $createException], |
|||
["exportBadDir/file", "jane.doe@example.com", false, $createException], |
|||
]; |
|||
} |
|||
|
|||
/** @dataProvider provideFileImports */ |
|||
public function testImportFromAFile(string $file, string $user, bool $flat, bool $replace, $exp) { |
|||
$path = $this->path.$file; |
|||
try { |
|||
if ($exp instanceof \JKingWeb\Arsse\AbstractException) { |
|||
$this->assertException($exp); |
|||
$this->proc->importFile($path, $user, $flat, $replace); |
|||
} else { |
|||
$this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace)); |
|||
} |
|||
} finally { |
|||
\Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace); |
|||
} |
|||
} |
|||
|
|||
public function provideFileImports() { |
|||
$missingException = new Exception("fileMissing"); |
|||
$permissionException = new Exception("fileUnreadable"); |
|||
return [ |
|||
["importGoodFile", "john.doe@example.com", true, true, true], |
|||
["importBadFile", "john.doe@example.com", true, true, $permissionException], |
|||
["importNonFile", "john.doe@example.com", true, true, $missingException], |
|||
["importGoodFile", "john.doe@example.com", true, false, true], |
|||
["importBadFile", "john.doe@example.com", true, false, $permissionException], |
|||
["importNonFile", "john.doe@example.com", true, false, $missingException], |
|||
["importGoodFile", "john.doe@example.com", false, true, true], |
|||
["importBadFile", "john.doe@example.com", false, true, $permissionException], |
|||
["importNonFile", "john.doe@example.com", false, true, $missingException], |
|||
["importGoodFile", "john.doe@example.com", false, false, true], |
|||
["importBadFile", "john.doe@example.com", false, false, $permissionException], |
|||
["importNonFile", "john.doe@example.com", false, false, $missingException], |
|||
["importGoodFile", "jane.doe@example.com", true, true, true], |
|||
["importBadFile", "jane.doe@example.com", true, true, $permissionException], |
|||
["importNonFile", "jane.doe@example.com", true, true, $missingException], |
|||
["importGoodFile", "jane.doe@example.com", true, false, true], |
|||
["importBadFile", "jane.doe@example.com", true, false, $permissionException], |
|||
["importNonFile", "jane.doe@example.com", true, false, $missingException], |
|||
["importGoodFile", "jane.doe@example.com", false, true, true], |
|||
["importBadFile", "jane.doe@example.com", false, true, $permissionException], |
|||
["importNonFile", "jane.doe@example.com", false, true, $missingException], |
|||
["importGoodFile", "jane.doe@example.com", false, false, true], |
|||
["importBadFile", "jane.doe@example.com", false, false, $permissionException], |
|||
["importNonFile", "jane.doe@example.com", false, false, $missingException], |
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,264 @@ |
|||
<?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\ImportExport; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Db\SQLite3\Driver; |
|||
use JKingWeb\Arsse\ImportExport\AbstractImportExport; |
|||
use JKingWeb\Arsse\ImportExport\Exception; |
|||
use JKingWeb\Arsse\Test\Database; |
|||
|
|||
/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */ |
|||
class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
protected $drv; |
|||
protected $proc; |
|||
protected $checkTables = [ |
|||
'arsse_folders' => ["id", "owner", "parent", "name"], |
|||
'arsse_feeds' => ["id", "url", "title"], |
|||
'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], |
|||
'arsse_tags' => ["id", "owner", "name"], |
|||
'arsse_tag_members' => ["tag", "subscription", "assigned"], |
|||
]; |
|||
|
|||
public function setUp() { |
|||
self::clearData(); |
|||
// create a mock user manager |
|||
Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); |
|||
\Phake::when(Arsse::$user)->exists->thenReturn(true); |
|||
\Phake::when(Arsse::$user)->authorize->thenReturn(true); |
|||
// create a mock Import/Export processor |
|||
$this->proc = \Phake::partialMock(AbstractImportExport::class); |
|||
// initialize an SQLite memeory database |
|||
static::setConf(); |
|||
try { |
|||
$this->drv = Driver::create(); |
|||
} catch (\JKingWeb\Arsse\Db\Exception $e) { |
|||
$this->markTestSkipped("An SQLite database is required for this test"); |
|||
} |
|||
// create the database interface with the suitable driver and apply the latest schema |
|||
Arsse::$db = new Database($this->drv); |
|||
Arsse::$db->driverSchemaUpdate(); |
|||
$this->data = [ |
|||
'arsse_users' => [ |
|||
'columns' => [ |
|||
'id' => 'str', |
|||
'password' => 'str', |
|||
], |
|||
'rows' => [ |
|||
["john.doe@example.com", ""], |
|||
["jane.doe@example.com", ""], |
|||
], |
|||
], |
|||
'arsse_folders' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'owner' => "str", |
|||
'parent' => "int", |
|||
'name' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1, "john.doe@example.com", null, "Science"], |
|||
[2, "john.doe@example.com", 1, "Rocketry"], |
|||
[3, "john.doe@example.com", null, "Politics"], |
|||
[4, "john.doe@example.com", null, "Photography"], |
|||
[5, "john.doe@example.com", 3, "Local"], |
|||
[6, "john.doe@example.com", 3, "National"], |
|||
], |
|||
], |
|||
'arsse_feeds' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'url' => "str", |
|||
'title' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1, "http://localhost:8000/Import/nasa-jpl", "NASA JPL"], |
|||
[2, "http://localhost:8000/Import/torstar", "Toronto Star"], |
|||
[3, "http://localhost:8000/Import/ars", "Ars Technica"], |
|||
[4, "http://localhost:8000/Import/cbc", "CBC News"], |
|||
[5, "http://localhost:8000/Import/citizen", "Ottawa Citizen"], |
|||
[6, "http://localhost:8000/Import/eurogamer", "Eurogamer"], |
|||
], |
|||
], |
|||
'arsse_subscriptions' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'owner' => "str", |
|||
'folder' => "int", |
|||
'feed' => "int", |
|||
'title' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1, "john.doe@example.com", 2, 1, "NASA JPL"], |
|||
[2, "john.doe@example.com", 5, 2, "Toronto Star"], |
|||
[3, "john.doe@example.com", 1, 3, "Ars Technica"], |
|||
[4, "john.doe@example.com", 6, 4, "CBC News"], |
|||
[5, "john.doe@example.com", 6, 5, "Ottawa Citizen"], |
|||
[6, "john.doe@example.com", null, 6, "Eurogamer"], |
|||
], |
|||
], |
|||
'arsse_tags' => [ |
|||
'columns' => [ |
|||
'id' => "int", |
|||
'owner' => "str", |
|||
'name' => "str", |
|||
], |
|||
'rows' => [ |
|||
[1, "john.doe@example.com", "canada"], |
|||
[2, "john.doe@example.com", "frequent"], |
|||
[3, "john.doe@example.com", "gaming"], |
|||
[4, "john.doe@example.com", "news"], |
|||
[5, "john.doe@example.com", "tech"], |
|||
[6, "john.doe@example.com", "toronto"], |
|||
], |
|||
], |
|||
'arsse_tag_members' => [ |
|||
'columns' => [ |
|||
'tag' => "int", |
|||
'subscription' => "int", |
|||
'assigned' => "bool", |
|||
], |
|||
'rows' => [ |
|||
[1, 2, 1], |
|||
[1, 4, 1], |
|||
[1, 5, 1], |
|||
[2, 3, 1], |
|||
[2, 6, 1], |
|||
[3, 6, 1], |
|||
[4, 2, 1], |
|||
[4, 4, 1], |
|||
[4, 5, 1], |
|||
[5, 1, 1], |
|||
[5, 3, 1], |
|||
[6, 2, 1], |
|||
], |
|||
], |
|||
]; |
|||
$this->primeDatabase($this->drv, $this->data); |
|||
} |
|||
|
|||
public function tearDown() { |
|||
$this->drv = null; |
|||
$this->proc = null; |
|||
self::clearData(); |
|||
} |
|||
|
|||
public function testImportForAMissingUser() { |
|||
\Phake::when(Arsse::$user)->exists->thenReturn(false); |
|||
$this->assertException("doesNotExist", "User"); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
} |
|||
|
|||
public function testImportWithInvalidFolder() { |
|||
$in = [[ |
|||
], [1 => |
|||
['id' => 1, 'name' => "", 'parent' => 0], |
|||
]]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->assertException("invalidFolderName", "ImportExport"); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
} |
|||
|
|||
public function testImportWithDuplicateFolder() { |
|||
$in = [[ |
|||
], [1 => |
|||
['id' => 1, 'name' => "New", 'parent' => 0], |
|||
['id' => 2, 'name' => "New", 'parent' => 0], |
|||
]]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->assertException("invalidFolderCopy", "ImportExport"); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
} |
|||
|
|||
public function testMakeNoEffectiveChanges() { |
|||
$in = [[ |
|||
['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], |
|||
['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], |
|||
['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], |
|||
['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], |
|||
['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], |
|||
['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC News", 'folder' => 6, 'tags' => ["news", "canada"]], |
|||
], [1 => |
|||
['id' => 1, 'name' => "Photography", 'parent' => 0], |
|||
['id' => 2, 'name' => "Science", 'parent' => 0], |
|||
['id' => 3, 'name' => "Rocketry", 'parent' => 2], |
|||
['id' => 4, 'name' => "Politics", 'parent' => 0], |
|||
['id' => 5, 'name' => "Local", 'parent' => 4], |
|||
['id' => 6, 'name' => "National", 'parent' => 4], |
|||
]]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$exp = $this->primeExpectations($this->data, $this->checkTables); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
$this->compareExpectations($this->drv, $exp); |
|||
$this->proc->import("john.doe@example.com", "", false, true); |
|||
$this->compareExpectations($this->drv, $exp); |
|||
} |
|||
|
|||
public function testModifyASubscription() { |
|||
$in = [[ |
|||
['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], |
|||
['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], |
|||
['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], |
|||
['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], |
|||
['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], |
|||
['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC", 'folder' => 0, 'tags' => ["news", "canada"]], // moved to root and renamed |
|||
], [1 => |
|||
['id' => 1, 'name' => "Photography", 'parent' => 0], |
|||
['id' => 2, 'name' => "Science", 'parent' => 0], |
|||
['id' => 3, 'name' => "Rocketry", 'parent' => 2], |
|||
['id' => 4, 'name' => "Politics", 'parent' => 0], |
|||
['id' => 5, 'name' => "Local", 'parent' => 4], |
|||
['id' => 6, 'name' => "National", 'parent' => 4], |
|||
]]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->proc->import("john.doe@example.com", "", false, true); |
|||
$exp = $this->primeExpectations($this->data, $this->checkTables); |
|||
$exp['arsse_subscriptions']['rows'][3] = [4, "john.doe@example.com", null, 4, "CBC"]; |
|||
$this->compareExpectations($this->drv, $exp); |
|||
} |
|||
|
|||
public function testImportAFeed() { |
|||
$in = [[ |
|||
['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => ["frequent", "cryptic"]], //one existing tag and one new one |
|||
], []]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
$exp = $this->primeExpectations($this->data, $this->checkTables); |
|||
$exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ |
|||
$exp['arsse_subscriptions']['rows'][] = [7, "john.doe@example.com", null, 7, "Some Feed"]; |
|||
$exp['arsse_tags']['rows'][] = [7, "john.doe@example.com", "cryptic"]; |
|||
$exp['arsse_tag_members']['rows'][] = [2, 7, 1]; |
|||
$exp['arsse_tag_members']['rows'][] = [7, 7, 1]; |
|||
$this->compareExpectations($this->drv, $exp); |
|||
} |
|||
|
|||
public function testImportAFeedWithAnInvalidTag() { |
|||
$in = [[ |
|||
['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => [""]], |
|||
], []]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->assertException("invalidTagName", "ImportExport"); |
|||
$this->proc->import("john.doe@example.com", "", false, false); |
|||
} |
|||
|
|||
public function testReplaceData() { |
|||
$in = [[ |
|||
['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 1, 'tags' => ["frequent", "cryptic"]], |
|||
], [1 => |
|||
['id' => 1, 'name' => "Photography", 'parent' => 0], |
|||
]]; |
|||
\Phake::when($this->proc)->parse->thenReturn($in); |
|||
$this->proc->import("john.doe@example.com", "", false, true); |
|||
$exp = $this->primeExpectations($this->data, $this->checkTables); |
|||
$exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ |
|||
$exp['arsse_subscriptions']['rows'] = [[7, "john.doe@example.com", 4, 7, "Some Feed"]]; |
|||
$exp['arsse_tags']['rows'] = [[2, "john.doe@example.com", "frequent"], [7, "john.doe@example.com", "cryptic"]]; |
|||
$exp['arsse_tag_members']['rows'] = [[2, 7, 1], [7, 7, 1]]; |
|||
$exp['arsse_folders']['rows'] = [[4, "john.doe@example.com", null, "Photography"]]; |
|||
$this->compareExpectations($this->drv, $exp); |
|||
} |
|||
} |
@ -0,0 +1,165 @@ |
|||
<?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\ImportExport; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\Test\Result; |
|||
use JKingWeb\Arsse\ImportExport\OPML; |
|||
use JKingWeb\Arsse\ImportExport\Exception; |
|||
|
|||
/** @covers \JKingWeb\Arsse\ImportExport\OPML<extended> */ |
|||
class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
protected $folders = [ |
|||
['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"], |
|||
['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"], |
|||
['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], |
|||
['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], |
|||
['id' => 2, 'parent' => 1, 'children' => 0, 'feeds' => 1, 'name' => "Rocketry"], |
|||
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], |
|||
]; |
|||
protected $subscriptions = [ |
|||
['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://example.com/3", 'favicon' => 'http://example.com/3.png'], |
|||
['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://example.com/4", 'favicon' => 'http://example.com/4.png'], |
|||
['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://example.com/6", 'favicon' => 'http://example.com/6.png'], |
|||
['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://example.com/1", 'favicon' => null], |
|||
['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://example.com/5", 'favicon' => ''], |
|||
['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://example.com/2", 'favicon' => 'http://example.com/2.png'], |
|||
]; |
|||
protected $tags = [ |
|||
['id' => 1, 'name' => "Canada", 'subscription' => 2], |
|||
['id' => 1, 'name' => "Canada", 'subscription' => 4], |
|||
['id' => 1, 'name' => "Canada", 'subscription' => 5], |
|||
['id' => 2, 'name' => "Politics", 'subscription' => 4], |
|||
['id' => 2, 'name' => "Politics", 'subscription' => 5], |
|||
['id' => 3, 'name' => "Science, etc", 'subscription' => 1], |
|||
['id' => 3, 'name' => "Science, etc", 'subscription' => 3], |
|||
// Eurogamer is untagged |
|||
]; |
|||
protected $serialization = <<<OPML_EXPORT_SERIALIZATION |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<opml version="2.0"> |
|||
<head/> |
|||
<body> |
|||
<outline text="Photography"/> |
|||
<outline text="Politics"> |
|||
<outline text="Local"> |
|||
<outline type="rss" text="Toronto Star" xmlUrl="http://example.com/2" category="Canada"/> |
|||
</outline> |
|||
<outline text="National"> |
|||
<outline type="rss" text="CBC News" xmlUrl="http://example.com/4" category="Canada,Politics"/> |
|||
<outline type="rss" text="Ottawa Citizen" xmlUrl="http://example.com/5" category="Canada,Politics"/> |
|||
</outline> |
|||
</outline> |
|||
<outline text="Science"> |
|||
<outline text="Rocketry"> |
|||
<outline type="rss" text="NASA JPL" xmlUrl="http://example.com/1" category="Science etc"/> |
|||
</outline> |
|||
<outline type="rss" text="Ars Technica" xmlUrl="http://example.com/3" category="Science etc"/> |
|||
</outline> |
|||
<outline type="rss" text="Eurogamer" xmlUrl="http://example.com/6"/> |
|||
</body> |
|||
</opml> |
|||
OPML_EXPORT_SERIALIZATION; |
|||
protected $serializationFlat = <<<OPML_EXPORT_SERIALIZATION |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<opml version="2.0"> |
|||
<head/> |
|||
<body> |
|||
<outline type="rss" text="Ars Technica" xmlUrl="http://example.com/3" category="Science etc"/> |
|||
<outline type="rss" text="CBC News" xmlUrl="http://example.com/4" category="Canada,Politics"/> |
|||
<outline type="rss" text="Eurogamer" xmlUrl="http://example.com/6"/> |
|||
<outline type="rss" text="NASA JPL" xmlUrl="http://example.com/1" category="Science etc"/> |
|||
<outline type="rss" text="Ottawa Citizen" xmlUrl="http://example.com/5" category="Canada,Politics"/> |
|||
<outline type="rss" text="Toronto Star" xmlUrl="http://example.com/2" category="Canada"/> |
|||
</body> |
|||
</opml> |
|||
OPML_EXPORT_SERIALIZATION; |
|||
|
|||
public function setUp() { |
|||
self::clearData(); |
|||
Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); |
|||
Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); |
|||
\Phake::when(Arsse::$user)->exists->thenReturn(true); |
|||
} |
|||
|
|||
public function testExportToOpml() { |
|||
\Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); |
|||
\Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); |
|||
\Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); |
|||
$this->assertXmlStringEqualsXmlString($this->serialization, (new OPML)->export("john.doe@example.com")); |
|||
} |
|||
|
|||
public function testExportToFlatOpml() { |
|||
\Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); |
|||
\Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); |
|||
\Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); |
|||
$this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); |
|||
} |
|||
|
|||
public function testExportToOpmlAMissingUser() { |
|||
\Phake::when(Arsse::$user)->exists->thenReturn(false); |
|||
$this->assertException("doesNotExist", "User"); |
|||
(new OPML)->export("john.doe@example.com"); |
|||
} |
|||
|
|||
/** @dataProvider provideParserData */ |
|||
public function testParseOpmlForImport(string $file, bool $flat, $exp) { |
|||
$data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); |
|||
// set up a partial mock to make the ImportExport::parse() method visible |
|||
$parser = \Phake::makeVisible(\Phake::partialMock(OPML::class)); |
|||
if ($exp instanceof \JKingWeb\Arsse\AbstractException) { |
|||
$this->assertException($exp); |
|||
$parser->parse($data, $flat); |
|||
} else { |
|||
$this->assertSame($exp, $parser->parse($data, $flat)); |
|||
} |
|||
} |
|||
|
|||
public function provideParserData() { |
|||
return [ |
|||
["BrokenXML.opml", false, new Exception("invalidSyntax")], |
|||
["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], |
|||
["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], |
|||
["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], |
|||
["BrokenOPML.4.opml", false, new Exception("invalidSemantics")], |
|||
["Empty.1.opml", false, [[], []]], |
|||
["Empty.2.opml", false, [[], []]], |
|||
["Empty.3.opml", false, [[], []]], |
|||
["FeedsOnly.opml", false, [[ |
|||
['url' => "http://example.com/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []], |
|||
['url' => "http://example.com/2", 'title' => "", 'folder' => 0, 'tags' => []], |
|||
['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []], |
|||
['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []], |
|||
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], |
|||
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]], |
|||
], []]], |
|||
["FoldersOnly.opml", true, [[], []]], |
|||
["FoldersOnly.opml", false, [[], [1 => |
|||
['id' => 1, 'name' => "Folder 1", 'parent' => 0], |
|||
['id' => 2, 'name' => "Folder 2", 'parent' => 0], |
|||
['id' => 3, 'name' => "Also a folder", 'parent' => 2], |
|||
['id' => 4, 'name' => "Still a folder", 'parent' => 2], |
|||
['id' => 5, 'name' => "Folder 5", 'parent' => 4], |
|||
['id' => 6, 'name' => "Folder 6", 'parent' => 0], |
|||
]]], |
|||
["MixedContent.opml", false, [[ |
|||
['url' => "https://www.jpl.nasa.gov/multimedia/rss/news.xml", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], |
|||
['url' => "http://feeds.arstechnica.com/arstechnica/index/", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], |
|||
['url' => "https://www.thestar.com/content/thestar/feed.RSSManagerServlet.topstories.rss", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], |
|||
['url' => "http://rss.canada.com/get/?F239", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], |
|||
['url' => "https://www.eurogamer.net/?format=rss", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], |
|||
], [1 => |
|||
['id' => 1, 'name' => "Photography", 'parent' => 0], |
|||
['id' => 2, 'name' => "Science", 'parent' => 0], |
|||
['id' => 3, 'name' => "Rocketry", 'parent' => 2], |
|||
['id' => 4, 'name' => "Politics", 'parent' => 0], |
|||
['id' => 5, 'name' => "Local", 'parent' => 4], |
|||
['id' => 6, 'name' => "National", 'parent' => 4], |
|||
]]], |
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
<?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\Fever\PDO; |
|||
|
|||
/** @covers \JKingWeb\Arsse\REST\Fever\API<extended> |
|||
* @group optional */ |
|||
class TestAPI extends \JKingWeb\Arsse\TestCase\REST\Fever\TestAPI { |
|||
use \JKingWeb\Arsse\Test\PDOTest; |
|||
} |
@ -0,0 +1,514 @@ |
|||
<?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\Fever; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\User; |
|||
use JKingWeb\Arsse\Database; |
|||
use JKingWeb\Arsse\Test\Result; |
|||
use JKingWeb\Arsse\Context\Context; |
|||
use JKingWeb\Arsse\Db\ExceptionInput; |
|||
use JKingWeb\Arsse\Db\Transaction; |
|||
use JKingWeb\Arsse\REST\Fever\API; |
|||
use Psr\Http\Message\ResponseInterface; |
|||
use Zend\Diactoros\ServerRequest; |
|||
use Zend\Diactoros\Response\JsonResponse; |
|||
use Zend\Diactoros\Response\XmlResponse; |
|||
use Zend\Diactoros\Response\EmptyResponse; |
|||
|
|||
/** @covers \JKingWeb\Arsse\REST\Fever\API<extended> */ |
|||
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
/** @var \JKingWeb\Arsse\REST\Fever\API */ |
|||
protected $h; |
|||
|
|||
protected $articles = [ |
|||
'db' => [ |
|||
[ |
|||
'id' => 101, |
|||
'url' => 'http://example.com/1', |
|||
'title' => 'Article title 1', |
|||
'author' => '', |
|||
'content' => '<p>Article content 1</p>', |
|||
'published_date' => '2000-01-01 00:00:00', |
|||
'unread' => 1, |
|||
'starred' => 0, |
|||
'subscription' => 8, |
|||
], |
|||
[ |
|||
'id' => 102, |
|||
'url' => 'http://example.com/2', |
|||
'title' => 'Article title 2', |
|||
'author' => '', |
|||
'content' => '<p>Article content 2</p>', |
|||
'published_date' => '2000-01-02 00:00:00', |
|||
'unread' => 0, |
|||
'starred' => 0, |
|||
'subscription' => 8, |
|||
], |
|||
[ |
|||
'id' => 103, |
|||
'url' => 'http://example.com/3', |
|||
'title' => 'Article title 3', |
|||
'author' => '', |
|||
'content' => '<p>Article content 3</p>', |
|||
'published_date' => '2000-01-03 00:00:00', |
|||
'unread' => 1, |
|||
'starred' => 1, |
|||
'subscription' => 9, |
|||
], |
|||
[ |
|||
'id' => 104, |
|||
'url' => 'http://example.com/4', |
|||
'title' => 'Article title 4', |
|||
'author' => '', |
|||
'content' => '<p>Article content 4</p>', |
|||
'published_date' => '2000-01-04 00:00:00', |
|||
'unread' => 0, |
|||
'starred' => 1, |
|||
'subscription' => 9, |
|||
], |
|||
[ |
|||
'id' => 105, |
|||
'url' => 'http://example.com/5', |
|||
'title' => 'Article title 5', |
|||
'author' => '', |
|||
'content' => '<p>Article content 5</p>', |
|||
'published_date' => '2000-01-05 00:00:00', |
|||
'unread' => 1, |
|||
'starred' => 0, |
|||
'subscription' => 10, |
|||
], |
|||
], |
|||
'rest' => [ |
|||
[ |
|||
'id' => 101, |
|||
'feed_id' => 8, |
|||
'title' => 'Article title 1', |
|||
'author' => '', |
|||
'html' => '<p>Article content 1</p>', |
|||
'url' => 'http://example.com/1', |
|||
'is_saved' => 0, |
|||
'is_read' => 0, |
|||
'created_on_time' => 946684800, |
|||
], |
|||
[ |
|||
'id' => 102, |
|||
'feed_id' => 8, |
|||
'title' => 'Article title 2', |
|||
'author' => '', |
|||
'html' => '<p>Article content 2</p>', |
|||
'url' => 'http://example.com/2', |
|||
'is_saved' => 0, |
|||
'is_read' => 1, |
|||
'created_on_time' => 946771200, |
|||
], |
|||
[ |
|||
'id' => 103, |
|||
'feed_id' => 9, |
|||
'title' => 'Article title 3', |
|||
'author' => '', |
|||
'html' => '<p>Article content 3</p>', |
|||
'url' => 'http://example.com/3', |
|||
'is_saved' => 1, |
|||
'is_read' => 0, |
|||
'created_on_time' => 946857600, |
|||
], |
|||
[ |
|||
'id' => 104, |
|||
'feed_id' => 9, |
|||
'title' => 'Article title 4', |
|||
'author' => '', |
|||
'html' => '<p>Article content 4</p>', |
|||
'url' => 'http://example.com/4', |
|||
'is_saved' => 1, |
|||
'is_read' => 1, |
|||
'created_on_time' => 946944000, |
|||
], |
|||
[ |
|||
'id' => 105, |
|||
'feed_id' => 10, |
|||
'title' => 'Article title 5', |
|||
'author' => '', |
|||
'html' => '<p>Article content 5</p>', |
|||
'url' => 'http://example.com/5', |
|||
'is_saved' => 0, |
|||
'is_read' => 0, |
|||
'created_on_time' => 947030400, |
|||
], |
|||
], |
|||
]; |
|||
protected function v($value) { |
|||
return $value; |
|||
} |
|||
|
|||
protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { |
|||
$url = "/fever/".$url; |
|||
$type = $type ?? "application/x-www-form-urlencoded"; |
|||
$server = [ |
|||
'REQUEST_METHOD' => $method, |
|||
'REQUEST_URI' => $url, |
|||
'HTTP_CONTENT_TYPE' => $type, |
|||
]; |
|||
$req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); |
|||
if (!is_array($dataGet)) { |
|||
parse_str($dataGet, $dataGet); |
|||
} |
|||
$req = $req->withRequestTarget($url)->withQueryParams($dataGet); |
|||
if (is_array($dataPost)) { |
|||
$req = $req->withParsedBody($dataPost); |
|||
} else { |
|||
parse_str($dataPost, $arr); |
|||
$req = $req->withParsedBody($arr); |
|||
} |
|||
if (isset($user)) { |
|||
if (strlen($user)) { |
|||
$req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); |
|||
} else { |
|||
$req = $req->withAttribute("authenticationFailed", true); |
|||
} |
|||
} |
|||
return $req; |
|||
} |
|||
|
|||
public function setUp() { |
|||
self::clearData(); |
|||
self::setConf(); |
|||
// create a mock user manager |
|||
Arsse::$user = \Phake::mock(User::class); |
|||
\Phake::when(Arsse::$user)->auth->thenReturn(true); |
|||
Arsse::$user->id = "john.doe@example.com"; |
|||
// create a mock database interface |
|||
Arsse::$db = \Phake::mock(Database::class); |
|||
\Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); |
|||
\Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); |
|||
// instantiate the handler as a partial mock to simplify testing |
|||
$this->h = \Phake::partialMock(API::class); |
|||
\Phake::when($this->h)->baseResponse->thenReturn([]); |
|||
} |
|||
|
|||
public function tearDown() { |
|||
self::clearData(); |
|||
} |
|||
|
|||
/** @dataProvider provideTokenAuthenticationRequests */ |
|||
public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { |
|||
self::setConf([ |
|||
'userHTTPAuthRequired' => $httpRequired, |
|||
'userSessionEnforced' => $tokenEnforced, |
|||
], true); |
|||
Arsse::$user->id = null; |
|||
\Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); |
|||
\Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); |
|||
// test only the authentication process |
|||
\Phake::when($this->h)->baseResponse->thenReturnCallback(function(bool $authenticated) { |
|||
return ['auth' => (int) $authenticated]; |
|||
}); |
|||
\Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { |
|||
return $out; |
|||
}); |
|||
$act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
public function provideTokenAuthenticationRequests() { |
|||
$success = new JsonResponse(['auth' => 1]); |
|||
$failure = new JsonResponse(['auth' => 0]); |
|||
$denied = new EmptyResponse(401); |
|||
return [ |
|||
[false, true, null, [], ['api' => null], $failure], |
|||
[false, false, null, [], ['api' => null], $failure], |
|||
[true, true, null, [], ['api' => null], $denied], |
|||
[true, false, null, [], ['api' => null], $denied], |
|||
[false, true, "", [], ['api' => null], $denied], |
|||
[false, false, "", [], ['api' => null], $denied], |
|||
[true, true, "", [], ['api' => null], $denied], |
|||
[true, false, "", [], ['api' => null], $denied], |
|||
[false, true, null, [], ['api' => null, 'api_key' => "validToken"], $failure], |
|||
[false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], |
|||
[true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[false, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[false, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[true, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[true, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], |
|||
[false, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], |
|||
[false, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], |
|||
[true, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], |
|||
[true, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], |
|||
[false, true, null, ['api_key' => "validToken"], ['api' => null], $success], |
|||
[false, false, null, ['api_key' => "validToken"], ['api' => null], $success], |
|||
[true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[true, false, null, ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[false, true, "", ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[false, false, "", ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[true, true, "", ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[true, false, "", ['api_key' => "validToken"], ['api' => null], $denied], |
|||
[false, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], |
|||
[false, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], |
|||
[true, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], |
|||
[true, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], |
|||
[false, true, null, ['api_key' => "invalidToken"], ['api' => null], $failure], |
|||
[false, false, null, ['api_key' => "invalidToken"], ['api' => null], $failure], |
|||
[true, true, null, ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[true, false, null, ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[false, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[false, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[true, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[true, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], |
|||
[false, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], |
|||
[false, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], |
|||
[true, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], |
|||
[true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], |
|||
]; |
|||
} |
|||
|
|||
public function testListGroups() { |
|||
\Phake::when(Arsse::$db)->tagList(Arsse::$user->id)->thenReturn(new Result([ |
|||
['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2], |
|||
['id' => 2, 'name' => "Interesting", 'subscriptions' => 2], |
|||
['id' => 3, 'name' => "Boring", 'subscriptions' => 0], |
|||
])); |
|||
\Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ |
|||
['id' => 1, 'name' => "Fascinating", 'subscription' => 1], |
|||
['id' => 1, 'name' => "Fascinating", 'subscription' => 2], |
|||
['id' => 2, 'name' => "Interesting", 'subscription' => 1], |
|||
['id' => 2, 'name' => "Interesting", 'subscription' => 3], |
|||
])); |
|||
$exp = new JsonResponse([ |
|||
'groups' => [ |
|||
['id' => 1, 'title' => "Fascinating"], |
|||
['id' => 2, 'title' => "Interesting"], |
|||
['id' => 3, 'title' => "Boring"], |
|||
], |
|||
'feeds_groups' => [ |
|||
['group_id' => 1, 'feed_ids' => "1,2"], |
|||
['group_id' => 2, 'feed_ids' => "1,3"], |
|||
], |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api&groups")); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
public function testListFeeds() { |
|||
\Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([ |
|||
['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'favicon' => "http://example.com/favicon.ico"], |
|||
['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'favicon' => ""], |
|||
['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'favicon' => "http://example.org/favicon.ico"], |
|||
])); |
|||
\Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ |
|||
['id' => 1, 'name' => "Fascinating", 'subscription' => 1], |
|||
['id' => 1, 'name' => "Fascinating", 'subscription' => 2], |
|||
['id' => 2, 'name' => "Interesting", 'subscription' => 1], |
|||
['id' => 2, 'name' => "Interesting", 'subscription' => 3], |
|||
])); |
|||
$exp = new JsonResponse([ |
|||
'feeds' => [ |
|||
['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], |
|||
['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")], |
|||
['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], |
|||
], |
|||
'feeds_groups' => [ |
|||
['group_id' => 1, 'feed_ids' => "1,2"], |
|||
['group_id' => 2, 'feed_ids' => "1,3"], |
|||
], |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api&feeds")); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
/** @dataProvider provideItemListContexts */ |
|||
public function testListItems(string $url, Context $c, bool $desc) { |
|||
$fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; |
|||
$order = [$desc ? "id desc" : "id"]; |
|||
\Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db'])); |
|||
\Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024); |
|||
$exp = new JsonResponse([ |
|||
'items' => $this->articles['rest'], |
|||
'total_items' => 1024, |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api&$url")); |
|||
$this->assertMessage($exp, $act); |
|||
\Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order); |
|||
} |
|||
|
|||
public function provideItemListContexts() { |
|||
$c = (new Context)->limit(50); |
|||
return [ |
|||
["items", (clone $c), false], |
|||
["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false], |
|||
["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false], |
|||
["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], |
|||
["items&since_id=1", (clone $c)->oldestArticle(2), false], |
|||
["items&max_id=2", (clone $c)->latestArticle(1), true], |
|||
["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false], |
|||
["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false], |
|||
["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true], |
|||
["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], |
|||
]; |
|||
} |
|||
|
|||
public function testListItemIds() { |
|||
$saved = [['id' => 1],['id' => 2],['id' => 3]]; |
|||
$unread = [['id' => 4],['id' => 5],['id' => 6]]; |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); |
|||
$exp = new JsonResponse([ |
|||
'saved_item_ids' => "1,2,3" |
|||
]); |
|||
$this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids"))); |
|||
$exp = new JsonResponse([ |
|||
'unread_item_ids' => "4,5,6" |
|||
]); |
|||
$this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids"))); |
|||
} |
|||
|
|||
public function testListHotLinks() { |
|||
// hot links are not actually implemented, so an empty array should be all we get |
|||
$exp = new JsonResponse([ |
|||
'links' => [] |
|||
]); |
|||
$this->assertMessage($exp, $this->h->dispatch($this->req("api&links"))); |
|||
} |
|||
|
|||
/** @dataProvider provideMarkingContexts */ |
|||
public function testSetMarks(string $post, Context $c, array $data, array $out) { |
|||
$saved = [['id' => 1],['id' => 2],['id' => 3]]; |
|||
$unread = [['id' => 4],['id' => 5],['id' => 6]]; |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); |
|||
\Phake::when(Arsse::$db)->articleMark->thenReturn(0); |
|||
\Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); |
|||
$exp = new JsonResponse($out); |
|||
$act = $this->h->dispatch($this->req("api", $post)); |
|||
$this->assertMessage($exp, $act); |
|||
if ($c && $data) { |
|||
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c); |
|||
} else { |
|||
\Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; |
|||
} |
|||
} |
|||
|
|||
public function provideMarkingContexts() { |
|||
$markRead = ['read' => true]; |
|||
$markUnread = ['read' => false]; |
|||
$markSaved = ['starred' => true]; |
|||
$markUnsaved = ['starred' => false]; |
|||
$listSaved = ['saved_item_ids' => "1,2,3"]; |
|||
$listUnread = ['unread_item_ids' => "4,5,6"]; |
|||
return [ |
|||
["mark=item&as=read&id=5", (new Context)->article(5), $markRead, $listUnread], |
|||
["mark=item&as=unread&id=42", (new Context)->article(42), $markUnread, $listUnread], |
|||
["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist |
|||
["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved], |
|||
["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved], |
|||
["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread], |
|||
["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread], |
|||
["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved], |
|||
["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved], |
|||
["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread], |
|||
["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread], |
|||
["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved], |
|||
["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved], |
|||
["mark=item&as=invalid&id=42", new Context, [], []], |
|||
["mark=invalid&as=unread&id=42", new Context, [], []], |
|||
["mark=group&as=read&id=0", (new Context), $markRead, $listUnread], |
|||
["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread], |
|||
["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved], |
|||
["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved], |
|||
["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread], |
|||
["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], |
|||
["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], |
|||
["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], |
|||
["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], |
|||
["mark=item&as=unread", new Context, [], []], |
|||
["mark=item&id=6", new Context, [], []], |
|||
["as=unread&id=6", new Context, [], []], |
|||
]; |
|||
} |
|||
|
|||
/** @dataProvider provideInvalidRequests */ |
|||
public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp) { |
|||
$this->assertMessage($exp, $this->h->dispatch($req)); |
|||
} |
|||
|
|||
public function provideInvalidRequests() { |
|||
return [ |
|||
'Not an API request' => [$this->req(""), new EmptyResponse(404)], |
|||
'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], |
|||
'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], |
|||
]; |
|||
} |
|||
|
|||
public function testMakeABaseQuery() { |
|||
$this->h = \Phake::partialMock(API::class); |
|||
\Phake::when($this->h)->logIn->thenReturn(true); |
|||
\Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z")); |
|||
$exp = new JsonResponse([ |
|||
'api_version' => API::LEVEL, |
|||
'auth' => 1, |
|||
'last_refreshed_on_time' => 946684800, |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api")); |
|||
$this->assertMessage($exp, $act); |
|||
\Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions |
|||
$exp = new JsonResponse([ |
|||
'api_version' => API::LEVEL, |
|||
'auth' => 1, |
|||
'last_refreshed_on_time' => null, |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api")); |
|||
$this->assertMessage($exp, $act); |
|||
\Phake::when($this->h)->logIn->thenReturn(false); |
|||
$exp = new JsonResponse([ |
|||
'api_version' => API::LEVEL, |
|||
'auth' => 0, |
|||
]); |
|||
$act = $this->h->dispatch($this->req("api")); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
public function testUndoReadMarks() { |
|||
$unread = [['id' => 4],['id' => 5],['id' => 6]]; |
|||
$out = ['unread_item_ids' => "4,5,6"]; |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]])); |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); |
|||
\Phake::when(Arsse::$db)->articleMark->thenReturn(0); |
|||
$exp = new JsonResponse($out); |
|||
$act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); |
|||
$this->assertMessage($exp, $act); |
|||
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")); |
|||
\Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([])); |
|||
$act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); |
|||
$this->assertMessage($exp, $act); |
|||
\Phake::verify(Arsse::$db)->articleMark; // only called one time, above |
|||
} |
|||
|
|||
public function testOutputToXml() { |
|||
\Phake::when($this->h)->processRequest->thenReturn([ |
|||
'items' => $this->articles['rest'], |
|||
'total_items' => 1024, |
|||
]); |
|||
$exp = new XmlResponse("<response><items><item><id>101</id><feed_id>8</feed_id><title>Article title 1</title><author></author><html><p>Article content 1</p></html><url>http://example.com/1</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>946684800</created_on_time></item><item><id>102</id><feed_id>8</feed_id><title>Article title 2</title><author></author><html><p>Article content 2</p></html><url>http://example.com/2</url><is_saved>0</is_saved><is_read>1</is_read><created_on_time>946771200</created_on_time></item><item><id>103</id><feed_id>9</feed_id><title>Article title 3</title><author></author><html><p>Article content 3</p></html><url>http://example.com/3</url><is_saved>1</is_saved><is_read>0</is_read><created_on_time>946857600</created_on_time></item><item><id>104</id><feed_id>9</feed_id><title>Article title 4</title><author></author><html><p>Article content 4</p></html><url>http://example.com/4</url><is_saved>1</is_saved><is_read>1</is_read><created_on_time>946944000</created_on_time></item><item><id>105</id><feed_id>10</feed_id><title>Article title 5</title><author></author><html><p>Article content 5</p></html><url>http://example.com/5</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>947030400</created_on_time></item></items><total_items>1024</total_items></response>"); |
|||
$act = $this->h->dispatch($this->req("api=xml")); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
public function testListFeedIcons() { |
|||
$act = $this->h->dispatch($this->req("api&favicons")); |
|||
$exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
|
|||
public function testAnswerOptionsRequest() { |
|||
$act = $this->h->dispatch($this->req("api", "", "OPTIONS")); |
|||
$exp = new EmptyResponse(204, [ |
|||
'Allow' => "POST", |
|||
'Accept' => "application/x-www-form-urlencoded", |
|||
]); |
|||
$this->assertMessage($exp, $act); |
|||
} |
|||
} |
@ -0,0 +1,94 @@ |
|||
<?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\Fever; |
|||
|
|||
use JKingWeb\Arsse\Arsse; |
|||
use JKingWeb\Arsse\User; |
|||
use JKingWeb\Arsse\Database; |
|||
use JKingWeb\Arsse\Db\ExceptionInput; |
|||
use JKingWeb\Arsse\User\Exception as UserException; |
|||
use JKingWeb\Arsse\Db\Transaction; |
|||
use JKingWeb\Arsse\REST\Fever\User as FeverUser; |
|||
|
|||
/** @covers \JKingWeb\Arsse\REST\Fever\User<extended> */ |
|||
class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
protected $u; |
|||
|
|||
public function setUp() { |
|||
self::clearData(); |
|||
self::setConf(); |
|||
// create a mock user manager |
|||
Arsse::$user = \Phake::mock(User::class); |
|||
\Phake::when(Arsse::$user)->auth->thenReturn(true); |
|||
// create a mock database interface |
|||
Arsse::$db = \Phake::mock(Database::class); |
|||
\Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); |
|||
// instantiate the handler |
|||
$this->u = new FeverUser(); |
|||
} |
|||
|
|||
public function tearDown() { |
|||
self::clearData(); |
|||
} |
|||
|
|||
/** @dataProvider providePasswordCreations */ |
|||
public function testRegisterAUserPassword(string $user, string $password = null, $exp) { |
|||
\Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); |
|||
\Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { |
|||
return $id ?? "RANDOM_TOKEN"; |
|||
}); |
|||
\Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); |
|||
try { |
|||
if ($exp instanceof \JKingWeb\Arsse\AbstractException) { |
|||
$this->assertException($exp); |
|||
$this->u->register($user, $password); |
|||
} else { |
|||
$this->assertSame($exp, $this->u->register($user, $password)); |
|||
} |
|||
} finally { |
|||
\Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); |
|||
\Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); |
|||
} |
|||
} |
|||
|
|||
public function providePasswordCreations() { |
|||
return [ |
|||
["jane.doe@example.com", "secret", "secret"], |
|||
["jane.doe@example.com", "superman", "superman"], |
|||
["jane.doe@example.com", null, "RANDOM_PASSWORD"], |
|||
["john.doe@example.org", null, new UserException("doesNotExist")], |
|||
["john.doe@example.net", null, "RANDOM_PASSWORD"], |
|||
["john.doe@example.net", "secret", "secret"], |
|||
]; |
|||
} |
|||
|
|||
public function testUnregisterAUser() { |
|||
\Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); |
|||
$this->assertTrue($this->u->unregister("jane.doe@example.com")); |
|||
\Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); |
|||
\Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); |
|||
$this->assertFalse($this->u->unregister("john.doe@example.com")); |
|||
\Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); |
|||
} |
|||
|
|||
/** @dataProvider provideUserAuthenticationRequests */ |
|||
public function testAuthenticateAUserName(string $user, string $password, bool $exp) { |
|||
\Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); |
|||
\Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); |
|||
\Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); |
|||
$this->assertSame($exp, $this->u->authenticate($user, $password)); |
|||
} |
|||
|
|||
public function provideUserAuthenticationRequests() { |
|||
return [ |
|||
["jane.doe@example.com", "secret", true], |
|||
["jane.doe@example.com", "superman", false], |
|||
["john.doe@example.com", "secret", false], |
|||
["john.doe@example.com", "superman", true], |
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,125 @@ |
|||
<?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\TinyTinyRSS; |
|||
|
|||
use JKingWeb\Arsse\Context\Context; |
|||
use JKingWeb\Arsse\REST\TinyTinyRSS\Search; |
|||
|
|||
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Search */ |
|||
class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { |
|||
public function provideSearchStrings() { |
|||
return [ |
|||
'Blank string' => ["", new Context], |
|||
'Whitespace only' => [" \n \t", new Context], |
|||
'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])], |
|||
'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])], |
|||
'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])], |
|||
'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])], |
|||
'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])], |
|||
'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])], |
|||
'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])], |
|||
'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])], |
|||
'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])], |
|||
'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])], |
|||
'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])], |
|||
'Empty quoted token' => ['""', new Context], |
|||
'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])], |
|||
'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])], |
|||
'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])], |
|||
'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])], |
|||
'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])], |
|||
'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])], |
|||
'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])], |
|||
'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])], |
|||
'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])], |
|||
'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])], |
|||
'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])], |
|||
'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])], |
|||
'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])], |
|||
'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])], |
|||
'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)], |
|||
'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)], |
|||
'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)], |
|||
'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)], |
|||
'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)], |
|||
'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)], |
|||
'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)], |
|||
'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)], |
|||
'Bare star tag true' => ['STAR:true', (new Context)->starred(true)], |
|||
'Bare star tag false' => ['STAR:false', (new Context)->starred(false)], |
|||
'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)], |
|||
'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)], |
|||
'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)], |
|||
'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)], |
|||
'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)], |
|||
'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)], |
|||
'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)], |
|||
'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)], |
|||
'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)], |
|||
'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)], |
|||
'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)], |
|||
'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)], |
|||
'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)], |
|||
'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)], |
|||
'Bare pub tag true' => ['PUB:true', null], |
|||
'Bare pub tag false' => ['PUB:false', new Context], |
|||
'Bare negative pub tag true' => ['-pub:true', new Context], |
|||
'Bare negative pub tag false' => ['-pub:false', null], |
|||
'Quoted pub tag true' => ['"PUB:true"', null], |
|||
'Quoted pub tag false' => ['"PUB:false"', new Context], |
|||
'Quoted negative pub tag true' => ['"-pub:true"', new Context], |
|||
'Quoted negative pub tag false' => ['"-pub:false"', null], |
|||
'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])], |
|||
'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])], |
|||
'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])], |
|||
'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])], |
|||
'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])], |
|||
'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])], |
|||
'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])], |
|||
'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])], |
|||
'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])], |
|||
'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])], |
|||
'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])], |
|||
'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])], |
|||
'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])], |
|||
'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])], |
|||
'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])], |
|||
'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])], |
|||
'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])], |
|||
'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])], |
|||
'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], |
|||
'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], |
|||
'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])], |
|||
'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])], |
|||
'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])], |
|||
'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])], |
|||
'Contradictory booleans' => ['unread:true -unread:true', null], |
|||
'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], |
|||
'Bare blank date' => ['@', new Context], |
|||
'Quoted blank date' => ['"@"', new Context], |
|||
'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
'Invalid date' => ['@Bugaboo', new Context], |
|||
'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], |
|||
'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], |
|||
'Escaped quoted date 3' => ['"@Yesterday\\', new Context], |
|||
'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], |
|||
'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], |
|||
'Contradictory dates' => ['@Yesterday @Today', null], |
|||
'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], |
|||
]; |
|||
} |
|||
|
|||
/** @dataProvider provideSearchStrings */ |
|||
public function testApplySearchToContext(string $search, $exp) { |
|||
$act = Search::parse($search); |
|||
$this->assertEquals($exp, $act); |
|||
} |
|||
} |
@ -0,0 +1,2 @@ |
|||
<html/> |
|||
<!-- Not an OPML document --> |
@ -0,0 +1,2 @@ |
|||
<opml/> |
|||
<!-- Not body element --> |
@ -0,0 +1,6 @@ |
|||
<opml> |
|||
<head> |
|||
<body/> |
|||
</head> |
|||
</opml> |
|||
<!-- No body as child of root --> |
@ -0,0 +1,5 @@ |
|||
<opml> |
|||
<body/> |
|||
<body/> |
|||
</opml> |
|||
<!-- Only one body is allowed --> |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue