diff --git a/lib/Database.php b/lib/Database.php index a5c7781..043b993 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -21,6 +21,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions + * - Tags, which belong to users and can be assigned to multiple subscriptions * - Feeds to which users are subscribed * - Articles, which belong to feeds and for which users can only affect metadata * - Editions, identifying authorial modifications to articles @@ -849,6 +850,22 @@ class Database { return $out; } + /** Returns an indexed array listing the tags assigned to a subscription + * + * @param string $user The user whose tags are to be listed + * @param integer $id The numeric identifier of the subscription whose tags are to be listed + * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) + */ + public function subscriptionTagsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->subscriptionValidateId($user, $id, true); + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll(); + return $out ? array_column($out, $field) : []; + } + /** Retrieves the URL of the icon for a subscription. * * Note that while the $user parameter is optional, it @@ -1505,11 +1522,9 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); - // flatten the result to return just the label ID or name, sorted - $out = $out ? array_column($out, !$byName ? "id" : "name") : []; - sort($out); - return $out; + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field", "str", "int")->run($user, $id)->getAll(); + return $out ? array_column($out, $field) : []; } /** Returns the author-supplied categories associated with an article */ @@ -1846,22 +1861,28 @@ class Database { // validate the label ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; $context = $context ?? new Context; - $out = 0; - // wrap this UPDATE and INSERT together into a transaction - $tr = $this->begin(); + // prepare either one or two queries // first update any existing entries with the removal or re-addition of their association - $q = $this->articleQuery($user, $context); - $q->pushCTE("target_articles"); - $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); - $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q1 = $this->articleQuery($user, $context); + $q1->pushCTE("target_articles"); + $q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); + $v1 = $q1->getValues(); + $q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes()); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->pushCTE("target_articles"); - $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); - $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q2 = $this->articleQuery($user, $context, ["id", "subscription"]); + $q2->pushCTE("target_articles"); + $q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); + $v2 = $q2->getValues(); + $q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes()); + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); } - // commit the transaction $tr->commit(); return $out; } @@ -1912,4 +1933,297 @@ class Database { return true; } } + + /** Creates a tag, and returns its numeric identifier + * + * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags + * + * @param string $user The user who will own the created tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + */ + public function tagAdd(string $user, array $data): int { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag name + $name = array_key_exists("name", $data) ? $data['name'] : ""; + $this->tagValidateName($name, true); + // perform the insert + return $this->db->prepare("INSERT INTO arsse_tags(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); + } + + /** Lists a user's subscription tags + * + * The following keys are included in each record: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The user whose tags are to be listed + * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them + */ + public function tagList(string $user, bool $includeEmpty = true): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT * FROM ( + SELECT + id,name,coalesce(subscriptions,0) as subscriptions + from arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE owner = ? + ) as tag_data + where subscriptions >= ? order by name + ", + "str", + "int" + )->run($user, !$includeEmpty); + } + + /** Lists the associations between all tags and subscription + * + * The following keys are included in each record: + * + * - "tag_id": The tag's numeric identifier + * - "tag_name" The tag's textual name + * - "subscription_id": The numeric identifier of the associated subscription + * - "subscription_name" The subscription's textual name + * + * @param string $user The user whose tags are to be listed + */ + public function tagSummarize(string $user): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT + arsse_tags.id as tag_id, + arsse_tags.name as tag_name, + arsse_subscriptions.id as subscription_id, + coalesce(arsse_subscriptions.title, arsse_feeds.title) as subscription_name + FROM arsse_tag_members + join arsse_tags on arsse_tags.id = arsse_tag_members.tag + join arsse_subscriptions on arsse_subscriptions.id = arsse_tag_members.subscription + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + WHERE arsse_tags.owner = ? and assigned = 1", + "str" + )->run($user); + } + + /** Deletes a tag from the database + * + * Any subscriptions associated with the tag remains untouched + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagRemove(string $user, $id, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $changes = $this->db->prepare("DELETE FROM arsse_tags where owner = ? and $field = ?", "str", $type)->run($user, $id)->changes(); + if (!$changes) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return true; + } + + /** Retrieves the properties of a tag + * + * The following keys are included in the output array: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $out = $this->db->prepare( + "SELECT + id,name,coalesce(subscriptions,0) as subscriptions + FROM arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE $field = ? and owner = ? + ", + $type, + "str" + )->run($id, $user)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Sets the properties of a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + if (isset($data['name'])) { + $this->tagValidateName($data['name']); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $valid = [ + 'name' => "str", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_tags set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and $field = ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Returns an indexed array of subscription identifiers assigned to a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // just do a syntactic check on the tag ID + $this->tagValidateId($user, $id, $byName, false); + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $out = $this->db->prepare("SELECT subscription from arsse_tag_members join arsse_tags on tag = id where assigned = 1 and $field = ? and owner = ? order by subscription", $type, "str")->run($id, $user)->getAll(); + if (!$out) { + // if no results were returned, do a full validation on the tag ID + $this->tagValidateId($user, $id, $byName, true, true); + // if the validation passes, return the empty result + return $out; + } else { + // flatten the result to return just the subscription IDs in a simple array + return array_column($out, "subscription"); + } + } + + /** Makes or breaks associations between a given tag and specified subscriptions + * + * @param string $user The owner of the tag + * @param integer|string $id The numeric identifier or name of the tag + * @param integer[] $context The query context matching the desired subscriptions + * @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag ID, and get the numeric ID if matching by name + $id = $this->tagValidateId($user, $id, $byName, true)['id']; + // prepare either one or two queries + list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); + // first update any existing entries with the removal or re-addition of their association + $q1 = $this->db->prepare( + "UPDATE arsse_tag_members + set assigned = ?, modified = CURRENT_TIMESTAMP + where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", + "bool", + "int", + "bool", + "str", + $inTypes + ); + $v1 = [!$remove, $id, !$remove, $user, $inValues]; + // next, if we're not removing, add any new entries that need to be added + if (!$remove) { + $q2 = $this->db->prepare( + "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)", + "int", + "int", + "str", + $inTypes + ); + $v2 = [$id, $id, $user, $inValues]; + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); + } + $tr->commit(); + return $out; + } + + /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the tag if it exists + * + * @param string $user The user who owns the tag to be validated + * @param integer|string $id The numeric identifier or name of the tag to validate + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the tag exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the tag is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ + protected function tagValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { + if (!$byName && !ValueInfo::id($id)) { + // if we're not referring to a tag by name and the ID is invalid, throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "int > 0"]); + } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) { + // otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "string"]); + } elseif ($checkDb) { + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $l = $this->db->prepare("SELECT id,name from arsse_tags where $field = ? and owner = ?", $type, "str")->run($id, $user)->getRow(); + if (!$l) { + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "tag", 'id' => $id]); + } else { + return $l; + } + } + return [ + 'id' => !$byName ? $id : null, + 'name' => $byName ? $id : null, + ]; + } + + /** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */ + protected function tagValidateName($name): bool { + $info = ValueInfo::str($name); + if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { + throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); + } elseif ($info & ValueInfo::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); + } elseif (!($info & ValueInfo::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]); + } else { + return true; + } + } } diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index c87e62f..22d1723 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -21,7 +21,8 @@ trait ExceptionBuilder { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; case Driver::SQLITE_SCHEMA: - return [ExceptionRetry::class, 'schemaChange', $msg]; + // sometimes encountered with PDO, because PDO sucks + return [ExceptionRetry::class, 'schemaChange', $msg]; // @codeCoverageIgnore case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 219d4c0..9e140c4 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -23,8 +23,9 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesFolder; use SeriesFeed; use SeriesSubscription; - use SeriesArticle; use SeriesLabel; + use SeriesTag; + use SeriesArticle; use SeriesCleanup; /** @var \JKingWeb\Arsse\Db\Driver */ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index f2811f1..0adac9e 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -69,6 +69,33 @@ trait SeriesSubscription { [3,"john.doe@example.com",3,"Ook",2,0,1], ] ], + '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], + [2,1,1], + [2,3,1], + [3,2,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -447,4 +474,22 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionFavicon(-2112, $user); } + + public function testListTheTagsOfASubscription() { + $this->assertEquals([1,2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1)); + $this->assertEquals([2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3)); + $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1, true)); + $this->assertEquals(["Fascinating"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3, true)); + } + + public function testListTheTagsOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->subscriptionTagsGet($this->user, 101); + } + + public function testListTheTagsOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); + } } diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php new file mode 100644 index 0000000..ea40d41 --- /dev/null +++ b/tests/cases/Database/SeriesTag.php @@ -0,0 +1,395 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], + ], + '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($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($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($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($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($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 testListTagledSubscriptions() { + $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 testListTagledSubscriptionsForAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); + } + + public function testListTagledSubscriptionsForAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); + } + + public function testListTagledSubscriptionsWithoutAuthority() { + 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($state); + } + + public function testClearATagFromSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($state); + } + + public function testApplyATagToSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, 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($state); + } + + public function testClearATagFromSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($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 = [ + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 3, 'subscription_name' => "Subscription Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ]; + $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"); + } +}