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

Implement TTRSS feed subscription; fixes #92

This commit is contained in:
J. King 2017-10-03 10:43:09 -04:00
parent 97b0134e56
commit bd6f23692c
3 changed files with 169 additions and 4 deletions

View file

@ -496,7 +496,7 @@ class Database {
// create a complex query // create a complex query
$q = new Query( $q = new Query(
"SELECT "SELECT
arsse_subscriptions.id, arsse_subscriptions.id as id,
url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, url,favicon,source,folder,pinned,err_count,err_msg,order_type,added,
topmost.top as top_folder, topmost.top as top_folder,
coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title,

View file

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS; namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User; use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Service;
@ -164,7 +165,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return (int) $folder['id']; return (int) $folder['id'];
} }
} }
return false; return false; // @codeCoverageIgnore
case 10235: // parent folder does not exist; this returns false as an ID case 10235: // parent folder does not exist; this returns false as an ID
return false; return false;
default: // other errors related to input default: // other errors related to input
@ -226,6 +227,88 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return null; return null;
} }
protected function feedError(FeedException $e): array {
// N.B.: we don't return code 4 (multiple feeds discovered); we simply pick the first feed discovered
switch ($e->getCode()) {
case 10502: // invalid URL
return ['code' => 2, 'message' => $e->getMessage()];
case 10521: // no feeds discovered
return ['code' => 3, 'message' => $e->getMessage()];
case 10511:
case 10512:
case 10522: // malformed data
return ['code' => 6, 'message' => $e->getMessage()];
default: // unable to download
return ['code' => 5, 'message' => $e->getMessage()];
}
}
public function opSubscribeToFeed(array $data): array {
if (!isset($data['feed_url']) || !(ValueInfo::str($data['feed_url']) & ValueInfo::VALID)) {
// if the feed URL is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
// normalize input data
if (
(isset($data['category_id']) && !ValueInfo::id($data['category_id'], true)) ||
(isset($data['login']) && !(ValueInfo::str($data['login']) & ValueInfo::VALID)) ||
(isset($data['password']) && !(ValueInfo::str($data['password']) & ValueInfo::VALID))
) {
// if the category is not a valid ID or the feed username or password are not convertible to strings, also throw an error
throw new Exception("INCORRECT_USAGE");
}
$url = (string) $data['feed_url'];
$folder = isset($data['category_id']) ? (int) $data['category_id'] : null;
$fetchUser = isset($data['login']) ? (string) $data['login'] : "";
$fetchPassword = isset($data['password']) ? (string) $data['password'] : "";
// check to make sure the requested folder exists before doing anything else, if one is specified
if ($folder) {
try {
Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder);
} catch (ExceptionInput $e) {
// folder does not exist: TT-RSS is a bit weird in this case and returns a feed ID of 0. It checks the feed first, but we do not
return ['code' => 1, 'feed_id' => 0];
}
}
try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $url, $fetchUser, $fetchPassword);
} catch (ExceptionInput $e) {
// subscription already exists; retrieve the existing ID and return that with the correct code
for ($triedDiscovery = 0; $triedDiscovery <= 1; $triedDiscovery++) {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id);
$id = false;
foreach ($subs as $sub) {
if ($sub['url']===$url) {
$id = (int) $sub['id'];
break;
}
}
if ($id) {
break;
} elseif (!$triedDiscovery) {
// if we didn't find the ID we perform feed discovery for the next iteration; this is pretty messy: discovery ends up being done twice because it was already done in $db->subscriptionAdd()
try {
$url = Feed::discover($url, $fetchUser, $fetchPassword);
} catch(FeedException $e) {
// feed errors (handled above)
return $this->feedError($e);
}
}
}
return ['code' => 0, 'feed_id' => $id];
} catch (FeedException $e) {
// feed errors (handled above)
return $this->feedError($e);
}
// if all went well, move the new subscription to the requested folder (if one was requested)
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]);
} catch (ExceptionInput $e) {
// ignore errors
}
return ['code' => 1, 'feed_id' => $id];
}
public function opUnsubscribeFeed(array $data): array { public function opUnsubscribeFeed(array $data): array {
if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) {
// if the feed is invalid, throw an error // if the feed is invalid, throw an error

View file

@ -11,7 +11,8 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\Db\Transaction;
use Phake; use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API<extended> */ /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API<extended>
* @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */
class TestTinyTinyAPI extends Test\AbstractTest { class TestTinyTinyAPI extends Test\AbstractTest {
protected $h; protected $h;
protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler
@ -253,6 +254,7 @@ class TestTinyTinyAPI extends Test\AbstractTest {
$in = [ $in = [
['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"], ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"],
['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 1], ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 1],
['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 2112],
['op' => "addCategory", 'sid' => "PriestsOfSyrinx"], ['op' => "addCategory", 'sid' => "PriestsOfSyrinx"],
['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => ""], ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => ""],
['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => " "], ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => " "],
@ -260,6 +262,7 @@ class TestTinyTinyAPI extends Test\AbstractTest {
$db = [ $db = [
['name' => "Software", 'parent' => null], ['name' => "Software", 'parent' => null],
['name' => "Hardware", 'parent' => 1], ['name' => "Hardware", 'parent' => 1],
['name' => "Hardware", 'parent' => 2112],
]; ];
$out = [ $out = [
['id' => 2, 'name' => "Software", 'parent' => null], ['id' => 2, 'name' => "Software", 'parent' => null],
@ -272,6 +275,7 @@ class TestTinyTinyAPI extends Test\AbstractTest {
Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([$out[0], $out[2]])); Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([$out[0], $out[2]]));
Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result([$out[1]])); Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result([$out[1]]));
// set up mocks that produce errors // set up mocks that produce errors
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[2])->thenThrow(new ExceptionInput("idMissing")); // parent folder does not exist
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing"));
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => "", 'parent' => null])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => "", 'parent' => null])->thenThrow(new ExceptionInput("missing"));
Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace"));
@ -287,11 +291,14 @@ class TestTinyTinyAPI extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1]))));
Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false);
Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false);
// add a folder to a missing parent (silently fails)
$exp = $this->respGood(false);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2]))));
// add some invalid folders // add some invalid folders
$exp = $this->respErr("INCORRECT_USAGE"); $exp = $this->respErr("INCORRECT_USAGE");
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2]))));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3]))));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4]))));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5]))));
} }
public function testRemoveACategory() { public function testRemoveACategory() {
@ -399,6 +406,81 @@ class TestTinyTinyAPI extends Test\AbstractTest {
Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
} }
public function testAddASubscription() {
$in = [
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/0"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/1", 'category_id' => 42],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/2", 'category_id' => 2112],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/3"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Valid"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Invalid"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/6"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/7"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/8", 'category_id' => 47],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/9", 'category_id' => 1],
// these don't even query the database as the input is syntactically invalid
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx"],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => []],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => "", 'password' => []],
['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'category_id' => -1],
];
$db = [
[Arsse::$user->id, "http://example.com/0", "", ""],
[Arsse::$user->id, "http://example.com/1", "", ""],
[Arsse::$user->id, "http://example.com/2", "", ""],
[Arsse::$user->id, "http://example.com/3", "", ""],
[Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Valid", "", ""],
[Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Invalid", "", ""],
[Arsse::$user->id, "http://example.com/6", "", ""],
[Arsse::$user->id, "http://example.com/7", "", ""],
[Arsse::$user->id, "http://example.com/8", "", ""],
[Arsse::$user->id, "http://example.com/9", "", ""],
];
$out = [
['code' => 1, 'feed_id' => 2],
['code' => 5, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", new \PicoFeed\Client\UnauthorizedException()))->getMessage()],
['code' => 1, 'feed_id' => 0],
['code' => 0, 'feed_id' => 3],
['code' => 0, 'feed_id' => 1],
['code' => 3, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://localhost:8000/Feed/Discovery/Invalid", new \PicoFeed\Reader\SubscriptionNotFoundException()))->getMessage()],
['code' => 2, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", new \PicoFeed\Client\InvalidUrlException()))->getMessage()],
['code' => 6, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()))->getMessage()],
['code' => 1, 'feed_id' => 4],
['code' => 0, 'feed_id' => 4],
];
$list = [
['id' => 1, 'url' => "http://localhost:8000/Feed/Discovery/Feed"],
['id' => 2, 'url' => "http://example.com/0"],
['id' => 3, 'url' => "http://example.com/3"],
['id' => 4, 'url' => "http://example.com/9"],
];
Phake::when(Arsse::$db)->subscriptionAdd(...$db[0])->thenReturn(2);
Phake::when(Arsse::$db)->subscriptionAdd(...$db[1])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", new \PicoFeed\Client\UnauthorizedException()));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[2])->thenReturn(2);
Phake::when(Arsse::$db)->subscriptionAdd(...$db[3])->thenThrow(new ExceptionInput("constraintViolation"));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[4])->thenThrow(new ExceptionInput("constraintViolation"));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[5])->thenThrow(new ExceptionInput("constraintViolation"));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[6])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", new \PicoFeed\Client\InvalidUrlException()));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()));
Phake::when(Arsse::$db)->subscriptionAdd(...$db[8])->thenReturn(4);
Phake::when(Arsse::$db)->subscriptionAdd(...$db[9])->thenThrow(new ExceptionInput("constraintViolation"));
Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn(['id' => 42]);
Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 47)->thenReturn(['id' => 47]);
Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing"));
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true);
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 4, $this->anything())->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($list));
for ($a = 0; $a < (sizeof($in) - 4); $a++) {
$exp = $this->respGood($out[$a]);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Failed test $a");
}
$exp = $this->respErr("INCORRECT_USAGE");
for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) {
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Failed test $a");
}
Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]);
}
public function testRemoveASubscription() { public function testRemoveASubscription() {
$in = [ $in = [
['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42],