*/ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { /** @var \JKingWeb\Arsse\REST\Fever\API */ protected $h; protected $hMock; protected $userId = "john.doe@example.com"; protected $articles = [ 'db' => [ [ 'id' => 101, 'url' => 'http://example.com/1', 'title' => 'Article title 1', 'author' => '', 'content' => '

Article content 1

', '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' => '

Article content 2

', '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' => '

Article content 3

', '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' => '

Article content 4

', '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' => '

Article content 5

', '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' => '

Article content 1

', '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' => '

Article content 2

', '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' => '

Article content 3

', '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' => '

Article content 4

', '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' => '

Article content 5

', '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 $target = "", ?string $user = null): ResponseInterface { Arsse::$db = $this->dbMock->get(); $this->h = $this->hMock->get(); $prefix = "/fever/"; $url = $prefix.$target; $type = $type ?? "application/x-www-form-urlencoded"; return $this->h->dispatch($this->serverRequest($method, $url, $prefix, [], [], $dataPost, $type, $dataGet, $user)); } public function setUp(): void { self::clearData(); self::setConf(); // create a mock user manager $this->userMock = $this->mock(User::class); $this->userMock->auth->returns(true); Arsse::$user = $this->userMock->get(); Arsse::$user->id = $this->userId; // create a mock database interface $this->dbMock = $this->mock(Database::class); $this->dbMock->begin->returns($this->mock(Transaction::class)); $this->dbMock->tokenLookup->returns(['user' => "john.doe@example.com"]); // instantiate the handler as a partial mock to simplify testing $this->hMock = $this->partialMock(API::class); $this->hMock->baseResponse->returns([]); } /** @dataProvider provideTokenAuthenticationRequests */ public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp): void { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, 'userSessionEnforced' => $tokenEnforced, ], true); Arsse::$user->id = null; $this->dbMock->tokenLookup->throws(new ExceptionInput("subjectMissing")); $this->dbMock->tokenLookup->with("fever.login", "validtoken")->returns(['user' => "jane.doe@example.com"]); // test only the authentication process $this->hMock->baseResponse->does(function(bool $authenticated) { return ['auth' => (int) $authenticated]; }); $this->hMock->processRequest->does(function($out, $G, $P) { return $out; }); $this->assertMessage($exp, $this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); } public function provideTokenAuthenticationRequests(): iterable { $success = HTTP::respJson(['auth' => 1]); $failure = HTTP::respJson(['auth' => 0]); $denied = HTTP::respEmpty(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(): void { $this->dbMock->tagList->with($this->userId)->returns(new Result([ ['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2], ['id' => 2, 'name' => "Interesting", 'subscriptions' => 2], ['id' => 3, 'name' => "Boring", 'subscriptions' => 0], ])); $this->dbMock->tagSummarize->with($this->userId)->returns(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 = HTTP::respJson([ '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"], ], ]); $this->assertMessage($exp, $this->req("api&groups")); } public function testListFeeds(): void { $this->dbMock->subscriptionList->with($this->userId)->returns(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", 'icon_url' => "http://example.com/favicon.ico", 'icon_id' => 42], ['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", 'icon_url' => "", 'icon_id' => null], ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'icon_url' => "http://example.org/favicon.ico", 'icon_id' => 42], ])); $this->dbMock->tagSummarize->with($this->userId)->returns(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 = HTTP::respJson([ 'feeds' => [ ['id' => 1, 'favicon_id' => 42, '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' => 42, '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"], ], ]); $this->assertMessage($exp, $this->req("api&feeds")); } /** @dataProvider provideItemListContexts */ public function testListItems(string $url, Context $c, bool $desc): void { $fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; $order = [$desc ? "id desc" : "id"]; $this->dbMock->articleList->returns(new Result($this->articles['db'])); $this->dbMock->articleCount->with($this->userId, (new Context)->hidden(false))->returns(1024); $exp = HTTP::respJson([ 'items' => $this->articles['rest'], 'total_items' => 1024, ]); $this->assertMessage($exp, $this->req("api&$url")); $this->dbMock->articleList->calledWith($this->userId, $this->equalTo($c), $fields, $order); } public function provideItemListContexts(): iterable { $c = (new Context)->limit(50); return [ ["items", (clone $c)->hidden(false), false], ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4])->hidden(false), false], ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4])->hidden(false), false], ["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], ["items&since_id=1", (clone $c)->articleRange(2, null)->hidden(false), false], ["items&max_id=2", (clone $c)->articleRange(null, 1)->hidden(false), 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)->articleRange(null, 2)->hidden(false), true], ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->articleRange(7, null)->hidden(false), false], ]; } public function testListItemIds(): void { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved)); $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread)); $exp = HTTP::respJson(['saved_item_ids' => "1,2,3"]); $this->assertMessage($exp, $this->req("api&saved_item_ids")); $exp = HTTP::respJson(['unread_item_ids' => "4,5,6"]); $this->assertMessage($exp, $this->req("api&unread_item_ids")); } public function testListHotLinks(): void { // hot links are not actually implemented, so an empty array should be all we get $exp = HTTP::respJson(['links' => []]); $this->assertMessage($exp, $this->req("api&links")); } /** @dataProvider provideMarkingContexts */ public function testSetMarks(string $post, Context $c, array $data, array $out): void { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved)); $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread)); $this->dbMock->articleMark->returns(0); $this->dbMock->articleMark->with($this->userId, $this->anything(), (new Context)->article(2112))->throws(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); $exp = HTTP::respJson($out); $this->assertMessage($exp, $this->req("api", $post)); if ($c && $data) { $this->dbMock->articleMark->calledWith($this->userId, $data, $this->equalTo($c)); } else { $this->dbMock->articleMark->never()->called(); } } /** @dataProvider provideMarkingContexts */ public function testSetMarksWithQuery(string $get, Context $c, array $data, array $out): void { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved)); $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread)); $this->dbMock->articleMark->returns(0); $this->dbMock->articleMark->with($this->userId, $this->anything(), (new Context)->article(2112))->throws(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); $exp = HTTP::respJson($out); $this->assertMessage($exp, $this->req("api&$get")); if ($c && $data) { $this->dbMock->articleMark->calledWith($this->userId, $data, $this->equalTo($c)); } else { $this->dbMock->articleMark->never()->called(); } } public function provideMarkingContexts(): iterable { $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)->hidden(false), $markRead, $listUnread], ["mark=feed&as=unread&id=42", (new Context)->subscription(42)->hidden(false), $markUnread, $listUnread], ["mark=feed&as=saved&id=5", (new Context)->subscription(5)->hidden(false), $markSaved, $listSaved], ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42)->hidden(false), $markUnsaved, $listSaved], ["mark=group&as=read&id=5", (new Context)->tag(5)->hidden(false), $markRead, $listUnread], ["mark=group&as=unread&id=42", (new Context)->tag(42)->hidden(false), $markUnread, $listUnread], ["mark=group&as=saved&id=5", (new Context)->tag(5)->hidden(false), $markSaved, $listSaved], ["mark=group&as=unsaved&id=42", (new Context)->tag(42)->hidden(false), $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)->hidden(false), $markRead, $listUnread], ["mark=group&as=unread&id=0", (new Context)->hidden(false), $markUnread, $listUnread], ["mark=group&as=saved&id=0", (new Context)->hidden(false), $markSaved, $listSaved], ["mark=group&as=unsaved&id=0", (new Context)->hidden(false), $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)->markedRange(null, "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(string $get, string $post, string $method, ?string $type, ResponseInterface $exp): void { $this->assertMessage($exp, $this->req($get, $post, $method, $type)); } public function provideInvalidRequests(): iterable { return [ 'Not an API request' => ["", "", "POST", null, HTTP::respEmpty(404)], 'Wrong method' => ["api", "", "PUT", null, HTTP::respEmpty(405, ['Allow' => "OPTIONS,POST"])], 'Non-standard method' => ["api", "", "GET", null, HTTP::respJson([])], 'Wrong content type' => ["api", '{"api_key":"validToken"}', "POST", "application/json", HTTP::respJson([])], // some clients send nonsensical content types; Fever seems to have allowed this 'Non-standard content type' => ["api", '{"api_key":"validToken"}', "POST", "multipart/form-data; boundary=33b68964f0de4c1f-5144aa6caaa6e4a8-18bfaf416a1786c8-5c5053a45f221bc1", HTTP::respJson([])], // some clients send nonsensical content types; Fever seems to have allowed this ]; } public function testMakeABaseQuery(): void { $this->hMock->baseResponse->forwards(); $this->hMock->logIn->returns(true); $this->dbMock->subscriptionRefreshed->with($this->userId)->returns(new \DateTimeImmutable("2000-01-01T00:00:00Z")); $exp = HTTP::respJson([ 'api_version' => API::LEVEL, 'auth' => 1, 'last_refreshed_on_time' => 946684800, ]); $this->assertMessage($exp, $this->req("api")); $this->dbMock->subscriptionRefreshed->with($this->userId)->returns(null); // no subscriptions $exp = HTTP::respJson([ 'api_version' => API::LEVEL, 'auth' => 1, 'last_refreshed_on_time' => null, ]); $this->assertMessage($exp, $this->req("api")); $this->hMock->logIn->returns(false); $exp = HTTP::respJson([ 'api_version' => API::LEVEL, 'auth' => 0, ]); $this->assertMessage($exp, $this->req("api")); } public function testUndoReadMarks(): void { $unread = [['id' => 4],['id' => 5],['id' => 6]]; $out = ['unread_item_ids' => "4,5,6"]; $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->limit(1)->hidden(false)), ["marked_date"], ["marked_date desc"])->returns(new Result([['marked_date' => "2000-01-01 00:00:00"]])); $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(new Result($unread)); $this->dbMock->articleMark->returns(0); $exp = HTTP::respJson($out); $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1])); $this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedRange("1999-12-31T23:59:45Z", null)->hidden(false))); $this->dbMock->articleList->with($this->userId, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->returns(new Result([])); $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1])); $this->dbMock->articleMark->once()->called(); // only called one time, above } public function testOutputToXml(): void { $this->hMock->processRequest->returns([ 'items' => $this->articles['rest'], 'total_items' => 1024, ]); $exp = HTTP::respXml("1018Article title 1<p>Article content 1</p>http://example.com/1009466848001028Article title 2<p>Article content 2</p>http://example.com/2019467712001039Article title 3<p>Article content 3</p>http://example.com/3109468576001049Article title 4<p>Article content 4</p>http://example.com/41194694400010510Article title 5<p>Article content 5</p>http://example.com/5009470304001024"); $this->assertMessage($exp, $this->req("api=xml")); } public function testListFeedIcons(): void { $iconType = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_TYPE"))->getValue(); $iconData = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_DATA"))->getValue(); $this->dbMock->iconList->returns(new Result($this->v([ ['id' => 42, 'type' => "image/svg+xml", 'data' => ""], ['id' => 44, 'type' => null, 'data' => "IMAGE DATA"], ['id' => 47, 'type' => null, 'data' => null], ]))); $exp = HTTP::respJson(['favicons' => [ ['id' => 0, 'data' => $iconType.",".$iconData], ['id' => 42, 'data' => "image/svg+xml;base64,PHN2Zy8+"], ['id' => 44, 'data' => "application/octet-stream;base64,SU1BR0UgREFUQQ=="], ]]); $this->assertMessage($exp, $this->req("api&favicons")); } public function testAnswerOptionsRequest(): void { $exp = HTTP::respEmpty(204, [ 'Allow' => "POST", 'Accept' => "application/x-www-form-urlencoded, multipart/form-data", ]); $this->assertMessage($exp, $this->req("api", "", "OPTIONS")); } }