diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index 47decbf..51884ea 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -71,6 +71,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { 'disabled' => "boolean", 'ignore_http_cache' => "boolean", 'fetch_via_proxy' => "boolean", + 'entry_ids' => "array", // this is a special case: it is an array of integers + 'status' => "string", ]; protected const USER_META_MAP = [ // Miniflux ID // Arsse ID Default value @@ -146,7 +148,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { ], '/entries' => [ 'GET' => ["getEntries", false, false, false, true, []], - 'PUT' => ["updateEntries", false, false, true, false, []], + 'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]], ], '/entries/1' => [ 'GET' => ["getEntry", false, true, false, false, []], @@ -349,8 +351,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) || ($k === "category_id" && $body[$k] < 1) + || ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"])) ) { return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); + } elseif ($k === "entry_ids") { + foreach ($body[$k] as $v) { + if (gettype($v) !== "integer") { + return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422); + } elseif ($v < 1) { + return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); + } + } } } //normalize user-specific input @@ -368,7 +379,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } // check for any missing required values foreach ($req as $k) { - if (!isset($body[$k])) { + if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) { return new ErrorResponse(["MissingInputValue", 'field' => $k], 422); } } @@ -629,16 +640,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return new EmptyResponse(204); } - protected function markUserByNum(array $path): ResponseInterface { - // this function is restricted to the logged-in user - $user = Arsse::$user->propertiesGet(Arsse::$user->id, false); - if (((int) $path[1]) !== $user['num']) { - return new ErrorResponse("403", 403); - } - Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->hidden(false)); - return new EmptyResponse(204); - } - /** Returns a useful subset of user metadata * * The following keys are included: @@ -729,23 +730,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return new EmptyResponse(204); } - protected function markCategory(array $path): ResponseInterface { - $folder = $path[1] - 1; - $c = new Context; - if ($folder === 0) { - // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders - $c = $c->folderShallow($folder); - } else { - $c = $c->folder($folder); - } - try { - Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); - } catch (ExceptionInput $e) { - return new ErrorResponse("404", 404); - } - return new EmptyResponse(204); - } - protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array { $url = new Uri($sub['url']); return [ @@ -1106,6 +1090,77 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } } + protected function updateEntries(array $data): ResponseInterface { + if ($data['status'] === "read") { + $in = ['read' => true, 'hidden' => false]; + } elseif ($data['status'] === "unread") { + $in = ['read' => false, 'hidden' => false]; + } elseif ($data['status'] === "removed") { + $in = ['read' => true, 'hidden' => true]; + } + assert(isset($in), new \Exception("Unknown status specified")); + Arsse::$db->articleMark(Arsse::$user->id, $in, (new Context)->articles($data['entry_ids'])); + return new EmptyResponse(204); + } + + protected function massRead(Context $c): void { + Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c->hidden(false)); + } + + protected function markUserByNum(array $path): ResponseInterface { + // this function is restricted to the logged-in user + $user = Arsse::$user->propertiesGet(Arsse::$user->id, false); + if (((int) $path[1]) !== $user['num']) { + return new ErrorResponse("403", 403); + } + $this->massRead(new Context); + return new EmptyResponse(204); + } + + protected function markFeed(array $path): ResponseInterface { + try { + $this->massRead((new Context)->subscription((int) $path[1])); + } catch (ExceptionInput $e) { + return new ErrorResponse("404", 404); + } + return new EmptyResponse(204); + } + + protected function markCategory(array $path): ResponseInterface { + $folder = $path[1] - 1; + $c = new Context; + if ($folder === 0) { + // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders + $c->folderShallow($folder); + } else { + $c->folder($folder); + } + try { + $this->massRead($c); + } catch (ExceptionInput $e) { + return new ErrorResponse("404", 404); + } + return new EmptyResponse(204); + } + + protected function toggleEntryBookmark(array $path): ResponseInterface { + // NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does + $id = (int) $path[1]; + $c = (new Context)->article($id); + try { + $tr = Arsse::$db->begin(); + if (Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->starred(false))) { + Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], $c); + } else { + Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], $c); + } + $tr->commit(); + } catch (ExceptionInput $e) { + return new ErrorResponse("404", 404); + } + return new EmptyResponse(204); + } + public static function tokenGenerate(string $user, string $label): string { // Miniflux produces tokenss in base64url alphabet $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH))); diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index a2c6aa8..18982f3 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -398,13 +398,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112")); } - public function testMarkAllArticlesAsRead(): void { - \Phake::when(Arsse::$db)->articleMark->thenReturn(true); - $this->assertMessage(new ErrorResponse("403", 403), $this->req("PUT", "/users/1/mark-all-as-read")); - $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/users/42/mark-all-as-read")); - \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->hidden(false)); - } - public function testListCategories(): void { \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([ ['id' => 1, 'name' => "Science"], @@ -512,18 +505,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { ); } - public function testMarkACategoryAsRead(): void { - \Phake::when(Arsse::$db)->articleMark->thenReturn(1)->thenReturn(1)->thenThrow(new ExceptionInput("idMissing")); - $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/2/mark-all-as-read")); - $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/1/mark-all-as-read")); - $this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/categories/2112/mark-all-as-read")); - \Phake::inOrder( - \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(1)), - \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folderShallow(0)), - \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(2111)) - ); - } - public function testListFeeds(): void { \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS))); $exp = new Response(self::FEEDS_OUT); @@ -831,6 +812,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { } public function provideSingleEntryQueries(): iterable { + self::clearData(); $c = new Context; return [ ["/entries/42", (clone $c)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])], @@ -846,4 +828,97 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { ["/categories/1/entries/42", (clone $c)->folderShallow(0)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])], ]; } + + /** @dataProvider provideEntryMarkings */ + public function testMarkEntries(array $in, ?array $data, ResponseInterface $exp): void { + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + $this->assertMessage($exp, $this->req("PUT", "/entries", $in)); + if ($data) { + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, (new Context)->articles($in['entry_ids'])); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideEntryMarkings(): iterable { + self::clearData(); + return [ + [['status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)], + [['entry_ids' => [1]], null, new ErrorResponse(["MissingInputValue", 'field' => "status"], 422)], + [['entry_ids' => [], 'status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)], + [['entry_ids' => 1, 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "array", 'actual' => "integer"], 422)], + [['entry_ids' => ["1"], 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "integer", 'actual' => "string"], 422)], + [['entry_ids' => [1], 'status' => 1], null, new ErrorResponse(["InvalidInputType", 'field' => "status", 'expected' => "string", 'actual' => "integer"], 422)], + [['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids",], 422)], + [['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status",], 422)], + [['entry_ids' => [1, 2], 'status' => "read"], ['read' => true, 'hidden' => false], new EmptyResponse(204)], + [['entry_ids' => [1, 2], 'status' => "unread"], ['read' => false, 'hidden' => false], new EmptyResponse(204)], + [['entry_ids' => [1, 2], 'status' => "removed"], ['read' => true, 'hidden' => true], new EmptyResponse(204)], + ]; + } + + /** @dataProvider provideMassMarkings */ + public function testMassMarkEntries(string $url, Context $c, $out, ResponseInterface $exp): void { + if ($out instanceof \Exception) { + \Phake::when(Arsse::$db)->articleMark->thenThrow($out); + } else { + \Phake::when(Arsse::$db)->articleMark->thenReturn($out); + } + $this->assertMessage($exp, $this->req("PUT", $url)); + if ($out !== null) { + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => true], $c); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideMassMarkings(): iterable { + self::clearData(); + $c = (new Context)->hidden(false); + return [ + ["/users/42/mark-all-as-read", $c, 1123, new EmptyResponse(204)], + ["/users/2112/mark-all-as-read", $c, null, new ErrorResponse("403", 403)], + ["/feeds/47/mark-all-as-read", (clone $c)->subscription(47), 2112, new EmptyResponse(204)], + ["/feeds/2112/mark-all-as-read", (clone $c)->subscription(2112), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)], + ["/categories/47/mark-all-as-read", (clone $c)->folder(46), 1337, new EmptyResponse(204)], + ["/categories/2112/mark-all-as-read", (clone $c)->folder(2111), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)], + ["/categories/1/mark-all-as-read", (clone $c)->folderShallow(0), 6666, new EmptyResponse(204)], + ]; + } + + /** @dataProvider provideBookmarkTogglings */ + public function testToggleABookmark($before, ?bool $after, ResponseInterface $exp): void { + $c = (new Context)->article(2112); + \Phake::when(Arsse::$db)->articleMark->thenReturn(1); + if ($before instanceof \Exception) { + \Phake::when(Arsse::$db)->articleCount->thenThrow($before); + } else { + \Phake::when(Arsse::$db)->articleCount->thenReturn($before); + } + $this->assertMessage($exp, $this->req("PUT", "/entries/2112/bookmark")); + if ($after !== null) { + \Phake::inOrder( + \Phake::verify(Arsse::$db)->begin(), + \Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false)), + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['starred' => $after], $c), + \Phake::verify($this->transaction)->commit() + ); + } else { + \Phake::inOrder( + \Phake::verify(Arsse::$db)->begin(), + \Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false)) + ); + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + \Phake::verifyNoInteraction($this->transaction); + } + } + + public function provideBookmarkTogglings(): iterable { + self::clearData(); + return [ + [1, true, new EmptyResponse(204)], + [0, false, new EmptyResponse(204)], + [new ExceptionInput("subjectMissing"), null, new ErrorResponse("404", 404)], + ]; + } }