diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a8e3e8de..0db6601c 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -6,11 +6,24 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; +/* + +Protocol difference so far: + - handling of incorrect Content-Type and/or HTTP method is different + - TT-RSS accepts whitespace-only names; we do not + - TT-RSS allows two folders to share the same name under the same parent; we do not + - Session lifetime is much shorter by default (does TT-RSS even expire sessions?) + +*/ + + + class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; const VERSION = "17.4"; @@ -143,20 +156,126 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return Arsse::$db->folderAdd(Arsse::$user->id, $in); } catch (ExceptionInput $e) { switch ($e->getCode()) { - // folder already exists - case 10236: - // retrieve the ID of the existing folder; duplicating a category silently returns the existing one + case 10236: // folder already exists + // retrieve the ID of the existing folder; duplicating a folder silently returns the existing one $folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false); foreach ($folders as $folder) { if ($folder['name']==$in['name']) { return (int) $folder['id']; } } - // parent folder does not exist; this returns false as an ID - case 10235: return false; - // other errors related to input - default: throw new Exception("INCORRECT_USAGE"); + return false; + case 10235: // parent folder does not exist; this returns false as an ID + return false; + default: // other errors related to input + throw new Exception("INCORRECT_USAGE"); } } } + + public function opRemoveCategory(array $data) { + if (!isset($data['category_id']) || !ValueInfo::id($data['category_id'])) { + // if the folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + try { + // attempt to remove the folder + Arsse::$db->folderRemove(Arsse::$user->id, (int) $data['category_id']); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opMoveCategory(array $data) { + if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['parent_id']) || !ValueInfo::id($data['parent_id'], true)) { + // if the folder or parent is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'parent' => (int) $data['parent_id'], + ]; + try { + // try to move the folder + Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameCategory(array $data) { + if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['caption'])) { + // if the folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $info = ValueInfo::str($data['caption']); + if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the folder name is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => (string) $data['caption'], + ]; + try { + // try to rename the folder + Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opUnsubscribeFeed(array $data): array { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { + // if the feed is invalid, throw an error + throw new Exception("FEED_NOT_FOUND"); + } + try { + // attempt to remove the feed + Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']); + } catch(ExceptionInput $e) { + throw new Exception("FEED_NOT_FOUND"); + } + return ['status' => "OK"]; + } + + public function opMoveFeed(array $data) { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) { + // if the feed or folder is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'folder' => (int) $data['category_id'], + ]; + try { + // try to move the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } + + public function opRenameFeed(array $data) { + if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['caption'])) { + // if the feed is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $info = ValueInfo::str($data['caption']); + if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the feed name is invalid, throw an error + throw new Exception("INCORRECT_USAGE"); + } + $in = [ + 'name' => (string) $data['caption'], + ]; + try { + // try to rename the feed + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + } catch(ExceptionInput $e) { + // ignore all errors + } + return null; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index a712b135..37aa594c 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -293,4 +293,212 @@ class TestTinyTinyAPI extends Test\AbstractTest { $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])))); } + + public function testRemoveACategory() { + $in = [ + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112], + ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1], + ]; + Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a folder + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // try deleting it again (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // delete a folder which does not exist (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // delete an invalid folder (causes an error) + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); + } + + public function testMoveACategory() { + $in = [ + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'parent_id' => 2], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 0], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 47], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'parent_id' => 1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => -1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'parent_id' => -1], + ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['parent' => 1]], + [Arsse::$user->id, 2112, ['parent' => 2]], + [Arsse::$user->id, 42, ['parent' => 0]], + [Arsse::$user->id, 42, ['parent' => 47]], + ]; + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("idMissing")); + // succefully move a folder + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // move a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // move a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $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])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $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])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(4))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRenameACategory() { + $in = [ + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'caption' => "Eek"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Eek"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => ""], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => " "], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['name' => "Ook"]], + [Arsse::$user->id, 2112, ['name' => "Eek"]], + [Arsse::$user->id, 42, ['name' => "Eek"]], + ]; + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully rename a folder + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // rename a folder which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // rename a folder causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $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[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRemoveASubscription() { + $in = [ + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], + ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"], + ]; + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); + // succefully delete a folder + $exp = $this->respGood(['status' => "OK"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // try deleting it again (this should noisily fail, as should everything else) + $exp = $this->respErr("FEED_NOT_FOUND"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $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[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + Phake::verify(Arsse::$db, Phake::times(3))->subscriptionRemove(Arsse::$user->id, $this->anything()); + } + + public function testMoveASubscription() { + $in = [ + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'category_id' => 2], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 0], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 47], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'category_id' => 1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => -1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'category_id' => -1], + ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['folder' => 1]], + [Arsse::$user->id, 2112, ['folder' => 2]], + [Arsse::$user->id, 42, ['folder' => 0]], + [Arsse::$user->id, 42, ['folder' => 47]], + ]; + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully move a subscription + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // move a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // move a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $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])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $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])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } + + public function testRenameASubscription() { + $in = [ + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'caption' => "Eek"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Eek"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => ""], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => " "], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"], + ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx"], + ]; + $db = [ + [Arsse::$user->id, 42, ['name' => "Ook"]], + [Arsse::$user->id, 2112, ['name' => "Eek"]], + [Arsse::$user->id, 42, ['name' => "Eek"]], + ]; + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); + // succefully rename a subscription + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + // rename a subscription which does not exist (this should silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + // rename a subscription causing a duplication (this should also silently fail) + $exp = $this->respGood(); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + // all the rest should cause errors + $exp = $this->respErr("INCORRECT_USAGE"); + $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[5])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); + Phake::verify(Arsse::$db, Phake::times(3))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + } }