From 173264eaab4fb51924344c8a190e81232ba0288c Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sun, 10 May 2020 01:04:18 -0400 Subject: [PATCH 1/5] Add Twitter User Object and Factory --- src/DI.php | 8 ++ src/Factory/Api/Twitter/User.php | 55 +++++++++++ src/Object/Api/Twitter/User.php | 153 +++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/Factory/Api/Twitter/User.php create mode 100644 src/Object/Api/Twitter/User.php diff --git a/src/DI.php b/src/DI.php index c89315c0ec..9ed0c5b24d 100644 --- a/src/DI.php +++ b/src/DI.php @@ -279,6 +279,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Relationship::class); } + /** + * @return Factory\Api\Twitter\User + */ + public static function twitterUser() + { + return self::$dice->create(Factory\Api\Twitter\User::class); + } + /** * @return Factory\Notification\Notification */ diff --git a/src/Factory/Api/Twitter/User.php b/src/Factory/Api/Twitter/User.php new file mode 100644 index 0000000000..6c3c3cc1fc --- /dev/null +++ b/src/Factory/Api/Twitter/User.php @@ -0,0 +1,55 @@ +. + * + */ + +namespace Friendica\Factory\Api\Twitter; + +use Friendica\BaseFactory; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Network\HTTPException; + +class User extends BaseFactory +{ + /** + * @param int $contactId + * @param int $uid Public contact (=0) or owner user id + * @param bool $skip_status + * @param bool $include_user_entities + * @return \Friendica\Object\Api\Twitter\User + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromContactId(int $contactId, $uid = 0, $skip_status = false, $include_user_entities = true) + { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if (!empty($cdata)) { + $publicContact = Contact::getById($cdata['public']); + $userContact = Contact::getById($cdata['user']); + } else { + $publicContact = Contact::getById($contactId); + $userContact = []; + } + + $apcontact = APContact::getByURL($publicContact['url'], false); + + return new \Friendica\Object\Api\Twitter\User($publicContact, $apcontact, $userContact, $skip_status, $include_user_entities); + } +} diff --git a/src/Object/Api/Twitter/User.php b/src/Object/Api/Twitter/User.php new file mode 100644 index 0000000000..c646b49241 --- /dev/null +++ b/src/Object/Api/Twitter/User.php @@ -0,0 +1,153 @@ +. + * + */ + +namespace Friendica\Object\Api\Twitter; + +use Friendica\BaseEntity; +use Friendica\Content\ContactSelector; +use Friendica\Content\Text\BBCode; + +/** + * @see https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/user-object + */ +class User extends BaseEntity +{ + /** @var int */ + protected $id; + /** @var string */ + protected $id_str; + /** @var string */ + protected $name; + /** @var string */ + protected $screen_name; + /** @var string|null */ + protected $location; + /** @var array */ + protected $derived; + /** @var string|null */ + protected $url; + /** @var array */ + protected $entities; + /** @var string|null */ + protected $description; + /** @var bool */ + protected $protected; + /** @var bool */ + protected $verified; + /** @var int */ + protected $followers_count; + /** @var int */ + protected $friends_count; + /** @var int */ + protected $listed_count; + /** @var int */ + protected $favourites_count; + /** @var int */ + protected $statuses_count; + /** @var string */ + protected $created_at; + /** @var string */ + protected $profile_banner_url; + /** @var string */ + protected $profile_image_url_https; + /** @var bool */ + protected $default_profile; + /** @var bool */ + protected $default_profile_image; + /** @var Status */ + protected $status; + /** @var array */ + protected $withheld_in_countries; + /** @var string */ + protected $withheld_scope; + + /** + * @param array $publicContact Full contact table record with uid = 0 + * @param array $apcontact Optional full apcontact table record + * @param array $userContact Optional full contact table record with uid != 0 + * @param bool $skip_status Whether to remove the last status property, currently unused + * @param bool $include_user_entities Whether to add the entities property + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $publicContact, array $apcontact = [], array $userContact = [], $skip_status = false, $include_user_entities = true) + { + $this->id = $publicContact['id']; + $this->id_str = (string) $publicContact['id']; + $this->name = $publicContact['name']; + $this->screen_name = $publicContact['nick'] ?: $publicContact['name']; + $this->location = $publicContact['location'] ?: + ContactSelector::networkToName($publicContact['network'], $publicContact['url'], $publicContact['protocol']); + $this->derived = []; + $this->url = $publicContact['url']; + // No entities needed since we don't perform any shortening in the URL or description + $this->entities = [ + 'url' => ['urls' => []], + 'description' => ['urls' => []], + ]; + if (!$include_user_entities) { + unset($this->entities); + } + $this->description = BBCode::toPlaintext($publicContact['about']); + $this->profile_image_url_https = $userContact['avatar'] ?? $publicContact['avatar']; + $this->protected = false; + $this->followers_count = $apcontact['followers_count'] ?? 0; + $this->friends_count = $apcontact['following_count'] ?? 0; + $this->listed_count = 0; + $this->created_at = api_date($publicContact['created']); + $this->favourites_count = 0; + $this->verified = false; + $this->statuses_count = $apcontact['statuses_count'] ?? 0; + $this->profile_banner_url = ''; + $this->default_profile = false; + $this->default_profile_image = false; + + // @TODO Replace skip_status parameter with an optional Status parameter + unset($this->status); + + // Unused optional fields + unset($this->withheld_in_countries); + unset($this->withheld_scope); + + // Deprecated + $this->profile_image_url = $userContact['avatar'] ?? $publicContact['avatar']; + $this->profile_image_url_profile_size = $publicContact['thumb']; + $this->profile_image_url_large = $publicContact['photo']; + $this->utc_offset = 0; + $this->time_zone = 'UTC'; + $this->geo_enabled = false; + $this->lang = null; + $this->contributors_enabled = false; + $this->is_translator = false; + $this->is_translation_enabled = false; + $this->following = false; + $this->follow_request_sent = false; + $this->statusnet_blocking = false; + $this->notifications = false; + + // Friendica-specific + $this->uid = $userContact['uid'] ?? 0; + $this->cid = $userContact['id'] ?? 0; + $this->pid = $publicContact['id']; + $this->self = $userContact['self'] ?? false; + $this->network = $publicContact['network']; + $this->statusnet_profile_url = $publicContact['url']; + } +} From 7a5afc10bb1dd56d71baac8c4ffcfdb592c9991c Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sun, 10 May 2020 01:09:20 -0400 Subject: [PATCH 2/5] Implement Twitter contact endpoints - Implement /followers/ids - Implement /followers/list - Reimplement /friends/ids - Reimplement /friends/list - Update API reference - Remove obsolete tests --- doc/API-Twitter.md | 26 ++- include/api.php | 90 --------- src/Module/Api/Twitter/ContactEndpoint.php | 202 +++++++++++++++++++++ src/Module/Api/Twitter/FollowersIds.php | 49 +++++ src/Module/Api/Twitter/FollowersList.php | 51 ++++++ src/Module/Api/Twitter/FriendsIds.php | 49 +++++ src/Module/Api/Twitter/FriendsList.php | 51 ++++++ static/routes.config.php | 4 + tests/include/ApiTest.php | 55 ------ 9 files changed, 422 insertions(+), 155 deletions(-) create mode 100644 src/Module/Api/Twitter/ContactEndpoint.php create mode 100644 src/Module/Api/Twitter/FollowersIds.php create mode 100644 src/Module/Api/Twitter/FollowersList.php create mode 100644 src/Module/Api/Twitter/FriendsIds.php create mode 100644 src/Module/Api/Twitter/FriendsList.php diff --git a/doc/API-Twitter.md b/doc/API-Twitter.md index d352e528de..86a3178752 100644 --- a/doc/API-Twitter.md +++ b/doc/API-Twitter.md @@ -152,19 +152,27 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [GET api/friendships/incoming](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-incoming) - Unsupported parameters - `stringify_ids` -- [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self -- [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self + +- - [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) + - [GET api/followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) + - [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) + - [GET api/friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) + - Additional parameter: + - `since_id`: Same behavior as `cursor`, use the `next_cursor` value to load the next page. + - Unsupported parameter: + - `skip_status`: No status is returned even if it isn't set to true. + - Caveats: + - `user_id` must be the ID of a contact associated with a local user account. + - `screen_name` must be associated with a local user account. + - `screen_name` trumps `user_id` if both are provided (undocumented Twitter behavior). + - Will succeed but return an empty array for users hiding their contact lists. - [POST api/friendships/destroy](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy) + + ## Non-implemented endpoints - [GET oauth/authenticate](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate) @@ -188,8 +196,6 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [POST lists/subscribers/destroy](https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-destroy) -- [GET followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) -- [GET friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) - [GET friendships/lookup](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-lookup) - [GET friendships/no_retweets/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-no_retweets-ids) - [GET friendships/outgoing](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-outgoing) diff --git a/include/api.php b/include/api.php index 90d0a511b7..d6a1993bf7 100644 --- a/include/api.php +++ b/include/api.php @@ -3580,96 +3580,6 @@ function api_statusnet_version($type) api_register_func('api/gnusocial/version', 'api_statusnet_version', false); api_register_func('api/statusnet/version', 'api_statusnet_version', false); -/** - * - * @param string $type Return type (atom, rss, xml, json) - * - * @param int $rel A contact relationship constant - * @return array|string|void - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @todo use api_format_data() to return data - */ -function api_ff_ids($type, int $rel) -{ - if (!api_user()) { - throw new ForbiddenException(); - } - - $a = DI::app(); - - api_get_user($a); - - $stringify_ids = $_REQUEST['stringify_ids'] ?? false; - - $contacts = DBA::p("SELECT `pcontact`.`id` - FROM `contact` - INNER JOIN `contact` AS `pcontact` - ON `contact`.`nurl` = `pcontact`.`nurl` - AND `pcontact`.`uid` = 0 - WHERE `contact`.`uid` = ? - AND NOT `contact`.`self` - AND `contact`.`rel` IN (?, ?)", - api_user(), - $rel, - Contact::FRIEND - ); - - $ids = []; - foreach (DBA::toArray($contacts) as $contact) { - if ($stringify_ids) { - $ids[] = $contact['id']; - } else { - $ids[] = intval($contact['id']); - } - } - - return api_format_data('ids', $type, ['id' => $ids]); -} - -/** - * Returns the ID of every user the user is following. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids - */ -function api_friends_ids($type) -{ - return api_ff_ids($type, Contact::SHARING); -} - -/** - * Returns the ID of every user following the user. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids - */ -function api_followers_ids($type) -{ - return api_ff_ids($type, Contact::FOLLOWER); -} - -/// @TODO move to top of file or somewhere better -api_register_func('api/friends/ids', 'api_friends_ids', true); -api_register_func('api/followers/ids', 'api_followers_ids', true); - /** * Sends a new direct message. * diff --git a/src/Module/Api/Twitter/ContactEndpoint.php b/src/Module/Api/Twitter/ContactEndpoint.php new file mode 100644 index 0000000000..0e45488e1e --- /dev/null +++ b/src/Module/Api/Twitter/ContactEndpoint.php @@ -0,0 +1,202 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Profile; +use Friendica\Model\User; +use Friendica\Module\BaseApi; +use Friendica\Model\Contact; +use Friendica\Network\HTTPException; +use Friendica\Util\Strings; + +abstract class ContactEndpoint extends BaseApi +{ + const DEFAULT_COUNT = 20; + const MAX_COUNT = 200; + + public static function init(array $parameters = []) + { + parent::init($parameters); + + if (!self::login()) { + throw new HTTPException\UnauthorizedException(); + } + } + + /** + * Computes the uid from the contact_id + screen_name parameters + * + * @param int|null $contact_id + * @param string $screen_name + * @return int + * @throws HTTPException\NotFoundException + */ + protected static function getUid(int $contact_id = null, string $screen_name = null) + { + $uid = self::$current_user_id; + + if ($contact_id || $screen_name) { + // screen_name trumps user_id when both are provided + if (!$screen_name) { + $contact = Contact::getById($contact_id, ['nick', 'url']); + // We don't have the followers of remote accounts so we check for locality + if (empty($contact) || !Strings::startsWith($contact['url'], DI::baseUrl()->get())) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Contact not found')); + } + + $screen_name = $contact['nick']; + } + + $user = User::getByNickname($screen_name, ['uid']); + if (empty($user)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found')); + } + + $uid = $user['uid']; + } + + return $uid; + } + + /** + * This methods expands the contact ids into full user objects in an existing result set. + * + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $skip_status + * @param bool $include_user_entities + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException + * @throws \ImagickException + */ + protected static function list($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $skip_status = false, bool $include_user_entities = true) + { + $return = self::ids($rel, $uid, $cursor, $count); + + $users = []; + foreach ($return['ids'] as $contactId) { + $users[] = DI::twitterUser()->createFromContactId($contactId, $uid, $skip_status, $include_user_entities); + } + + unset($return['ids']); + $return['users'] = $users; + + $return = [ + 'users' => $users, + 'next_cursor' => $return['next_cursor'], + 'next_cursor_str' => $return['next_cursor_str'], + 'previous_cursor' => $return['previous_cursor'], + 'previous_cursor_str' => $return['previous_cursor_str'], + 'total_count' => $return['total_count'], + ]; + + + + return $return; + } + + /** + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $stringify_ids + * @return array + * @throws HTTPException\NotFoundException + */ + protected static function ids($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $stringify_ids = false) + { + $hide_friends = false; + if ($uid != self::$current_user_id) { + $profile = Profile::getByUID($uid); + if (empty($profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Profile not found')); + } + + $hide_friends = (bool)$profile['hide-friends']; + } + + $condition = DBA::collapseCondition([ + 'rel' => $rel, + 'uid' => $uid, + 'self' => false, + 'deleted' => false, + 'hidden' => false, + 'archive' => false, + 'pending' => false + ]); + + if ($cursor !== -1) { + $condition[0] .= " AND `id` > ?"; + $condition[] = $cursor; + } + + $ids = []; + $next_cursor = 0; + $previous_cursor = 0; + $total_count = 0; + if (!$hide_friends) { + $total_count = DBA::count('contact', $condition); + + $contacts = Contact::selectToArray(['id'], $condition, ['limit' => $count, 'order' => ['id']]); + + // Contains user-specific contact ids + $ids = array_column($contacts, 'id'); + + // Cursor is on the user-specific contact id since it's the sort field + if (count($ids)) { + $next_cursor = $ids[count($ids) -1]; + } + + // Conversion to public contact ids + array_walk($ids, function (&$contactId) use ($uid, $stringify_ids) { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if ($stringify_ids) { + $contactId = (string)$cdata['public']; + } else { + $contactId = (int)$cdata['public']; + } + }); + + // No next page + if ($total_count <= count($contacts)) { + $next_cursor = 0; + } + } + + $return = [ + 'ids' => $ids, + 'next_cursor' => $next_cursor, + 'next_cursor_str' => (string)$next_cursor, + 'previous_cursor' => $previous_cursor, + 'previous_cursor_str' => (string)$previous_cursor, + 'total_count' => $total_count, + ]; + + return $return; + } +} diff --git a/src/Module/Api/Twitter/FollowersIds.php b/src/Module/Api/Twitter/FollowersIds.php new file mode 100644 index 0000000000..8e0a7da98b --- /dev/null +++ b/src/Module/Api/Twitter/FollowersIds.php @@ -0,0 +1,49 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids + */ +class FollowersIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + + System::jsonExit(self::ids( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FollowersList.php b/src/Module/Api/Twitter/FollowersList.php new file mode 100644 index 0000000000..867272bfba --- /dev/null +++ b/src/Module/Api/Twitter/FollowersList.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list + */ +class FollowersList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); + $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + + System::jsonExit(self::list( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsIds.php b/src/Module/Api/Twitter/FriendsIds.php new file mode 100644 index 0000000000..4086b17a50 --- /dev/null +++ b/src/Module/Api/Twitter/FriendsIds.php @@ -0,0 +1,49 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids + */ +class FriendsIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + + System::jsonExit(self::ids( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsList.php b/src/Module/Api/Twitter/FriendsList.php new file mode 100644 index 0000000000..e07f558f1d --- /dev/null +++ b/src/Module/Api/Twitter/FriendsList.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list + */ +class FriendsList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); + $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + + System::jsonExit(self::list( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/static/routes.config.php b/static/routes.config.php index 9fbe358830..074c1f5710 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -57,6 +57,10 @@ return [ '/profile/show' => [Module\Api\Friendica\Profile\Show::class , [R::GET ]], '/events' => [Module\Api\Friendica\Events\Index::class , [R::GET ]], ], + '/followers/ids' => [Module\Api\Twitter\FollowersIds::class , [R::GET ]], + '/followers/list' => [Module\Api\Twitter\FollowersList::class , [R::GET ]], + '/friends/ids' => [Module\Api\Twitter\FriendsIds::class , [R::GET ]], + '/friends/list' => [Module\Api\Twitter\FriendsList::class , [R::GET ]], ], '/admin' => [ diff --git a/tests/include/ApiTest.php b/tests/include/ApiTest.php index d6ce7576fe..e12b0cdd7d 100644 --- a/tests/include/ApiTest.php +++ b/tests/include/ApiTest.php @@ -2851,61 +2851,6 @@ class ApiTest extends DatabaseTest $this->assertEquals('0.9.7', $result['version']); } - /** - * Test the api_ff_ids() function. - * - * @return void - */ - public function testApiFfIds() - { - $result = api_ff_ids('json', Contact::FOLLOWER); - $this->assertEquals(['id' => []], $result); - } - - /** - * Test the api_ff_ids() function with a result. - * - * @return void - */ - public function testApiFfIdsWithResult() - { - $this->markTestIncomplete(); - } - - /** - * Test the api_ff_ids() function without an authenticated user. - * - * @return void - * @expectedException Friendica\Network\HTTPException\ForbiddenException - */ - public function testApiFfIdsWithoutAuthenticatedUser() - { - $_SESSION['authenticated'] = false; - api_ff_ids('json', Contact::FOLLOWER); - } - - /** - * Test the api_friends_ids() function. - * - * @return void - */ - public function testApiFriendsIds() - { - $result = api_friends_ids('json'); - $this->assertEquals(['id' => []], $result); - } - - /** - * Test the api_followers_ids() function. - * - * @return void - */ - public function testApiFollowersIds() - { - $result = api_followers_ids('json'); - $this->assertEquals(['id' => []], $result); - } - /** * Test the api_direct_messages_new() function. * From 8111ede2e5dede2f7e9c5be1b0dc9fd72bdff34d Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sun, 10 May 2020 10:24:35 -0400 Subject: [PATCH 3/5] Add support for max_id parameter in new Twitter contact API endpoints - Use filter_input instead of manual type casting --- doc/API-Twitter.md | 6 +- src/Module/Api/Twitter/ContactEndpoint.php | 65 ++++++++++++++-------- src/Module/Api/Twitter/FollowersIds.php | 21 +++++-- src/Module/Api/Twitter/FollowersList.php | 25 ++++++--- src/Module/Api/Twitter/FriendsIds.php | 21 +++++-- src/Module/Api/Twitter/FriendsList.php | 24 +++++--- 6 files changed, 112 insertions(+), 50 deletions(-) diff --git a/doc/API-Twitter.md b/doc/API-Twitter.md index 86a3178752..fab26ae5b5 100644 --- a/doc/API-Twitter.md +++ b/doc/API-Twitter.md @@ -157,11 +157,13 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [GET api/followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) - [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) - [GET api/friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) - - Additional parameter: - - `since_id`: Same behavior as `cursor`, use the `next_cursor` value to load the next page. + - Additional parameters: + - `since_id`: You can use the `next_cursor` value to load the next page. + - `max_id`: You can use the inverse of the `previous_cursor` value to load the previous page. - Unsupported parameter: - `skip_status`: No status is returned even if it isn't set to true. - Caveats: + - `cursor` trumps `since_id` trumps `max_id` if any combination is provided. - `user_id` must be the ID of a contact associated with a local user account. - `screen_name` must be associated with a local user account. - `screen_name` trumps `user_id` if both are provided (undocumented Twitter behavior). diff --git a/src/Module/Api/Twitter/ContactEndpoint.php b/src/Module/Api/Twitter/ContactEndpoint.php index 0e45488e1e..116f8eea2d 100644 --- a/src/Module/Api/Twitter/ContactEndpoint.php +++ b/src/Module/Api/Twitter/ContactEndpoint.php @@ -114,8 +114,6 @@ abstract class ContactEndpoint extends BaseApi 'total_count' => $return['total_count'], ]; - - return $return; } @@ -140,28 +138,33 @@ abstract class ContactEndpoint extends BaseApi $hide_friends = (bool)$profile['hide-friends']; } - $condition = DBA::collapseCondition([ - 'rel' => $rel, - 'uid' => $uid, - 'self' => false, - 'deleted' => false, - 'hidden' => false, - 'archive' => false, - 'pending' => false - ]); - - if ($cursor !== -1) { - $condition[0] .= " AND `id` > ?"; - $condition[] = $cursor; - } - $ids = []; $next_cursor = 0; $previous_cursor = 0; $total_count = 0; if (!$hide_friends) { + $condition = DBA::collapseCondition([ + 'rel' => $rel, + 'uid' => $uid, + 'self' => false, + 'deleted' => false, + 'hidden' => false, + 'archive' => false, + 'pending' => false + ]); + $total_count = DBA::count('contact', $condition); + if ($cursor !== -1) { + if ($cursor > 0) { + $condition[0] .= " AND `id` > ?"; + $condition[] = $cursor; + } else { + $condition[0] .= " AND `id` < ?"; + $condition[] = -$cursor; + } + } + $contacts = Contact::selectToArray(['id'], $condition, ['limit' => $count, 'order' => ['id']]); // Contains user-specific contact ids @@ -169,9 +172,32 @@ abstract class ContactEndpoint extends BaseApi // Cursor is on the user-specific contact id since it's the sort field if (count($ids)) { + $previous_cursor = -$ids[0]; $next_cursor = $ids[count($ids) -1]; } + // No next page + if ($total_count <= count($contacts) || count($contacts) < $count) { + $next_cursor = 0; + } + // End of results + if ($cursor < 0 && count($contacts) === 0) { + $next_cursor = -1; + } + + // No previous page + if ($cursor === -1) { + $previous_cursor = 0; + } + + if ($cursor > 0 && count($contacts) === 0) { + $previous_cursor = -$cursor; + } + + if ($cursor < 0 && count($contacts) === 0) { + $next_cursor = -1; + } + // Conversion to public contact ids array_walk($ids, function (&$contactId) use ($uid, $stringify_ids) { $cdata = Contact::getPublicAndUserContacID($contactId, $uid); @@ -181,11 +207,6 @@ abstract class ContactEndpoint extends BaseApi $contactId = (int)$cdata['public']; } }); - - // No next page - if ($total_count <= count($contacts)) { - $next_cursor = 0; - } } $return = [ diff --git a/src/Module/Api/Twitter/FollowersIds.php b/src/Module/Api/Twitter/FollowersIds.php index 8e0a7da98b..7b0bc84e07 100644 --- a/src/Module/Api/Twitter/FollowersIds.php +++ b/src/Module/Api/Twitter/FollowersIds.php @@ -32,16 +32,25 @@ class FollowersIds extends ContactEndpoint public static function rawContent(array $parameters = []) { // Expected value for user_id parameter: public/user contact id - $contact_id = $_GET['user_id'] ?? null; - $screen_name = $_GET['screen_name'] ?? null; - $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; - $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; - $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); System::jsonExit(self::ids( [Contact::FOLLOWER, Contact::FRIEND], self::getUid($contact_id, $screen_name), - $cursor, + $cursor ?? $since_id ?? - $max_id, $count, $stringify_ids )); diff --git a/src/Module/Api/Twitter/FollowersList.php b/src/Module/Api/Twitter/FollowersList.php index 867272bfba..7559d8327a 100644 --- a/src/Module/Api/Twitter/FollowersList.php +++ b/src/Module/Api/Twitter/FollowersList.php @@ -32,17 +32,28 @@ class FollowersList extends ContactEndpoint public static function rawContent(array $parameters = []) { // Expected value for user_id parameter: public/user contact id - $contact_id = $_GET['user_id'] ?? null; - $screen_name = $_GET['screen_name'] ?? null; - $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; - $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); - $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); - $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + $skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN); + $include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN); + + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); + System::jsonExit(self::list( [Contact::FOLLOWER, Contact::FRIEND], self::getUid($contact_id, $screen_name), - $cursor, + $cursor ?? $since_id ?? - $max_id, $count, $skip_status, $include_user_entities diff --git a/src/Module/Api/Twitter/FriendsIds.php b/src/Module/Api/Twitter/FriendsIds.php index 4086b17a50..1a303bfa75 100644 --- a/src/Module/Api/Twitter/FriendsIds.php +++ b/src/Module/Api/Twitter/FriendsIds.php @@ -32,16 +32,25 @@ class FriendsIds extends ContactEndpoint public static function rawContent(array $parameters = []) { // Expected value for user_id parameter: public/user contact id - $contact_id = $_GET['user_id'] ?? null; - $screen_name = $_GET['screen_name'] ?? null; - $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; - $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; - $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); System::jsonExit(self::ids( [Contact::SHARING, Contact::FRIEND], self::getUid($contact_id, $screen_name), - $cursor, + $cursor ?? $since_id ?? - $max_id, $count, $stringify_ids )); diff --git a/src/Module/Api/Twitter/FriendsList.php b/src/Module/Api/Twitter/FriendsList.php index e07f558f1d..1a45f07917 100644 --- a/src/Module/Api/Twitter/FriendsList.php +++ b/src/Module/Api/Twitter/FriendsList.php @@ -32,17 +32,27 @@ class FriendsList extends ContactEndpoint public static function rawContent(array $parameters = []) { // Expected value for user_id parameter: public/user contact id - $contact_id = $_GET['user_id'] ?? null; - $screen_name = $_GET['screen_name'] ?? null; - $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; - $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); - $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); - $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + $skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN); + $include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN); + + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); System::jsonExit(self::list( [Contact::SHARING, Contact::FRIEND], self::getUid($contact_id, $screen_name), - $cursor, + $cursor ?? $since_id ?? - $max_id, $count, $skip_status, $include_user_entities From 95138ae7d18ceb59eb46997c1af69b1e76994105 Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Tue, 9 Jun 2020 08:38:31 -0400 Subject: [PATCH 4/5] [Tests] Extract fixture logic from ApiTest - Reorder frio scheme tests to avoid PConfig persistence side-effect --- tests/FixtureTest.php | 53 +++++++++++++++++++++++++++++++++++ tests/include/ApiTest.php | 59 +++++++++++---------------------------- 2 files changed, 70 insertions(+), 42 deletions(-) create mode 100644 tests/FixtureTest.php diff --git a/tests/FixtureTest.php b/tests/FixtureTest.php new file mode 100644 index 0000000000..74abfdddbd --- /dev/null +++ b/tests/FixtureTest.php @@ -0,0 +1,53 @@ +dice = (new Dice()) + ->addRules(include __DIR__ . '/../static/dependencies.config.php') + ->addRule(Database::class, ['instanceOf' => StaticDatabase::class, 'shared' => true]) + ->addRule(ISession::class, ['instanceOf' => Session\Memory::class, 'shared' => true, 'call' => null]); + DI::init($this->dice); + + /** @var IConfig $config */ + $configCache = $this->dice->create(Cache::class); + $configCache->set('database', 'disable_pdo', true); + + /** @var Database $dba */ + $dba = $this->dice->create(Database::class); + + $dba->setTestmode(true); + + DBStructure::checkInitialValues(); + + // Load the API dataset for the whole API + $this->loadFixture(__DIR__ . '/datasets/api.fixture.php', $dba); + } +} diff --git a/tests/include/ApiTest.php b/tests/include/ApiTest.php index e12b0cdd7d..cce0541aa9 100644 --- a/tests/include/ApiTest.php +++ b/tests/include/ApiTest.php @@ -5,19 +5,12 @@ namespace Friendica\Test; -use Dice\Dice; use Friendica\App; use Friendica\Core\Config\IConfig; use Friendica\Core\PConfig\IPConfig; use Friendica\Core\Protocol; -use Friendica\Core\Session; -use Friendica\Core\Session\ISession; -use Friendica\Database\Database; -use Friendica\Database\DBStructure; use Friendica\DI; -use Friendica\Model\Contact; use Friendica\Network\HTTPException; -use Friendica\Test\Util\Database\StaticDatabase; use Friendica\Util\Temporal; use Monolog\Handler\TestHandler; @@ -29,7 +22,7 @@ require_once __DIR__ . '/../../include/api.php'; * Functions that use header() need to be tested in a separate process. * @see https://phpunit.de/manual/5.7/en/appendixes.annotations.html#appendixes.annotations.runTestsInSeparateProcesses */ -class ApiTest extends DatabaseTest +class ApiTest extends FixtureTest { /** * @var TestHandler Can handle log-outputs @@ -51,9 +44,6 @@ class ApiTest extends DatabaseTest /** @var IConfig */ protected $config; - /** @var Dice */ - protected $dice; - /** * Create variables used by tests. */ @@ -61,19 +51,6 @@ class ApiTest extends DatabaseTest { parent::setUp(); - $this->dice = (new Dice()) - ->addRules(include __DIR__ . '/../../static/dependencies.config.php') - ->addRule(Database::class, ['instanceOf' => StaticDatabase::class, 'shared' => true]) - ->addRule(ISession::class, ['instanceOf' => Session\Memory::class, 'shared' => true, 'call' => null]); - DI::init($this->dice); - - /** @var Database $dba */ - $dba = $this->dice->create(Database::class); - - $dba->setTestmode(true); - - DBStructure::checkInitialValues(); - /** @var IConfig $config */ $this->config = $this->dice->create(IConfig::class); @@ -88,8 +65,6 @@ class ApiTest extends DatabaseTest $this->config->set('system', 'throttle_limit_month', 100); $this->config->set('system', 'theme', 'system_theme'); - // Load the API dataset for the whole API - $this->loadFixture(__DIR__ . '/../datasets/api.fixture.php', $dba); /** @var App app */ $this->app = DI::app(); @@ -841,6 +816,22 @@ class ApiTest extends DatabaseTest $this->assertEquals('ededed', $user['profile_background_color']); } + /** + * Test the api_get_user() function with an empty Frio schema. + * + * @return void + */ + public function testApiGetUserWithEmptyFrioSchema() + { + $pConfig = $this->dice->create(IPConfig::class); + $pConfig->set($this->selfUser['id'], 'frio', 'schema', '---'); + $user = api_get_user($this->app); + $this->assertSelfUser($user); + $this->assertEquals('708fa0', $user['profile_sidebar_fill_color']); + $this->assertEquals('6fdbe8', $user['profile_link_color']); + $this->assertEquals('ededed', $user['profile_background_color']); + } + /** * Test the api_get_user() function with a custom Frio schema. * @@ -860,22 +851,6 @@ class ApiTest extends DatabaseTest $this->assertEquals('123456', $user['profile_background_color']); } - /** - * Test the api_get_user() function with an empty Frio schema. - * - * @return void - */ - public function testApiGetUserWithEmptyFrioSchema() - { - $pConfig = $this->dice->create(IPConfig::class); - $pConfig->set($this->selfUser['id'], 'frio', 'schema', '---'); - $user = api_get_user($this->app); - $this->assertSelfUser($user); - $this->assertEquals('708fa0', $user['profile_sidebar_fill_color']); - $this->assertEquals('6fdbe8', $user['profile_link_color']); - $this->assertEquals('ededed', $user['profile_background_color']); - } - /** * Test the api_get_user() function with an user that is not allowed to use the API. * From 5bfbc1224c21928b42e3306bbc6133c23012e1ce Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Tue, 9 Jun 2020 08:39:37 -0400 Subject: [PATCH 5/5] [Tests] Add test for Module\Api\Twitter\ContactEndpoint - Add necessary fixture data --- tests/datasets/api.fixture.php | 39 +++ .../Api/Twitter/ContactEndpointMock.php | 22 ++ .../Api/Twitter/ContactEndpointTest.php | 254 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 tests/src/Module/Api/Twitter/ContactEndpointMock.php create mode 100644 tests/src/Module/Api/Twitter/ContactEndpointTest.php diff --git a/tests/datasets/api.fixture.php b/tests/datasets/api.fixture.php index 54d4f4d3cf..8bdb868926 100644 --- a/tests/datasets/api.fixture.php +++ b/tests/datasets/api.fixture.php @@ -99,6 +99,45 @@ return [ 'rel' => 2, 'network' => 'dfrn', ], + [ + 'id' => 45, + 'uid' => 0, + 'name' => 'Friend contact', + 'nick' => 'friendcontact', + 'self' => 0, + 'nurl' => 'http://localhost/profile/friendcontact', + 'url' => 'http://localhost/profile/friendcontact', + 'pending' => 0, + 'blocked' => 0, + 'rel' => 2, + 'network' => 'dfrn', + ], + [ + 'id' => 46, + 'uid' => 42, + 'name' => 'Mutual contact', + 'nick' => 'mutualcontact', + 'self' => 0, + 'nurl' => 'http://localhost/profile/mutualcontact', + 'url' => 'http://localhost/profile/mutualcontact', + 'pending' => 0, + 'blocked' => 0, + 'rel' => 3, + 'network' => 'dfrn', + ], + [ + 'id' => 47, + 'uid' => 0, + 'name' => 'Mutual contact', + 'nick' => 'mutualcontact', + 'self' => 0, + 'nurl' => 'http://localhost/profile/mutualcontact', + 'url' => 'http://localhost/profile/mutualcontact', + 'pending' => 0, + 'blocked' => 0, + 'rel' => 2, + 'network' => 'dfrn', + ], ], 'item-uri' => [ [ diff --git a/tests/src/Module/Api/Twitter/ContactEndpointMock.php b/tests/src/Module/Api/Twitter/ContactEndpointMock.php new file mode 100644 index 0000000000..ff88717192 --- /dev/null +++ b/tests/src/Module/Api/Twitter/ContactEndpointMock.php @@ -0,0 +1,22 @@ +assertSame(42, ContactEndpointMock::getUid(42)); + $this->assertSame(42, ContactEndpointMock::getUid(null, 'selfcontact')); + $this->assertSame(42, ContactEndpointMock::getUid(84, 'selfcontact')); + } + + public function testGetUidContactIdNotFound() + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Contact not found'); + + ContactEndpointMock::getUid(84); + } + + public function testGetUidScreenNameNotFound() + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('User not found'); + + ContactEndpointMock::getUid(null, 'othercontact'); + } + + public function testGetUidContactIdScreenNameNotFound() + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('User not found'); + + ContactEndpointMock::getUid(42, 'othercontact'); + } + + public function testIds() + { + $expectedEmpty = [ + 'ids' => [], + 'next_cursor' => -1, + 'next_cursor_str' => '-1', + 'previous_cursor' => 0, + 'previous_cursor_str' => '0', + 'total_count' => 0, + ]; + + $this->assertSame($expectedEmpty, ContactEndpointMock::ids(Contact::FOLLOWER, 42)); + + $expectedFriend = [ + 'ids' => [47], + 'next_cursor' => 0, + 'next_cursor_str' => '0', + 'previous_cursor' => 0, + 'previous_cursor_str' => '0', + 'total_count' => 1, + ]; + + $this->assertSame($expectedFriend, ContactEndpointMock::ids(Contact::FRIEND, 42)); + $this->assertSame($expectedFriend, ContactEndpointMock::ids([Contact::FOLLOWER, Contact::FRIEND], 42)); + + $result = ContactEndpointMock::ids(Contact::SHARING, 42); + + $this->assertArrayHasKey('ids', $result); + $this->assertContainsOnly('int', $result['ids']); + $this->assertSame(45, $result['ids'][0]); + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42); + + $this->assertArrayHasKey('ids', $result); + $this->assertContainsOnly('int', $result['ids']); + $this->assertSame(45, $result['ids'][0]); + } + + /** + * @depends testIds + * + * @throws NotFoundException + */ + public function testIdsStringify() + { + $result = ContactEndpointMock::ids(Contact::SHARING, 42, -1, ContactEndpoint::DEFAULT_COUNT, true); + + $this->assertArrayHasKey('ids', $result); + $this->assertContainsOnly('string', $result['ids']); + $this->assertSame('45', $result['ids'][0]); + } + + public function testIdsPagination() + { + $expectedDefaultPageResult = [ + 'ids' => [45], + 'next_cursor' => 44, + 'next_cursor_str' => '44', + 'previous_cursor' => 0, + 'previous_cursor_str' => '0', + 'total_count' => 2, + ]; + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, -1, 1); + + $this->assertSame($expectedDefaultPageResult, $result); + + $nextPageCursor = $result['next_cursor']; + + $expectedSecondPageResult = [ + 'ids' => [47], + 'next_cursor' => 46, + 'next_cursor_str' => '46', + 'previous_cursor' => -46, + 'previous_cursor_str' => '-46', + 'total_count' => 2, + ]; + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $nextPageCursor, 1); + + $this->assertSame($expectedSecondPageResult, $result); + + $firstPageCursor = $result['previous_cursor']; + $emptyNextPageCursor = $result['next_cursor']; + + $expectedFirstPageResult = [ + 'ids' => [45], + 'next_cursor' => 44, + 'next_cursor_str' => '44', + 'previous_cursor' => -44, + 'previous_cursor_str' => '-44', + 'total_count' => 2, + ]; + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $firstPageCursor, 1); + + $this->assertSame($expectedFirstPageResult, $result); + + $emptyPrevPageCursor = $result['previous_cursor']; + + $expectedEmptyPrevPageResult = [ + 'ids' => [], + 'next_cursor' => -1, + 'next_cursor_str' => '-1', + 'previous_cursor' => 0, + 'previous_cursor_str' => '0', + 'total_count' => 2, + ]; + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $emptyPrevPageCursor, 1); + + $this->assertSame($expectedEmptyPrevPageResult, $result); + + $expectedEmptyNextPageResult = [ + 'ids' => [], + 'next_cursor' => 0, + 'next_cursor_str' => '0', + 'previous_cursor' => -46, + 'previous_cursor_str' => '-46', + 'total_count' => 2, + ]; + + $result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $emptyNextPageCursor, 1); + + $this->assertSame($expectedEmptyNextPageResult, $result); + } + + /** + * @depends testIds + * + * @throws NotFoundException + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function testList() + { + $expectedEmpty = [ + 'users' => [], + 'next_cursor' => -1, + 'next_cursor_str' => '-1', + 'previous_cursor' => 0, + 'previous_cursor_str' => '0', + 'total_count' => 0, + ]; + + $this->assertSame($expectedEmpty, ContactEndpointMock::list(Contact::FOLLOWER, 42)); + + $expectedFriendContactUser = [ + 'id' => 45, + 'id_str' => '45', + 'name' => 'Friend contact', + 'screen_name' => 'friendcontact', + 'location' => 'DFRN', + 'derived' => [], + 'url' => 'http://localhost/profile/friendcontact', + 'entities' => [ + 'url' => [ + 'urls' => [], + ], + 'description' => [ + 'urls' => [], + ], + ], + 'description' => '', + 'protected' => false, + 'verified' => false, + 'followers_count' => 0, + 'friends_count' => 0, + 'listed_count' => 0, + 'favourites_count' => 0, + 'statuses_count' => 0, + 'created_at' => 'Fri Feb 02 00:00:00 +0000 0000', + 'profile_banner_url' => '', + 'profile_image_url_https' => '', + 'default_profile' => false, + 'default_profile_image' => false, + 'profile_image_url' => '', + 'profile_image_url_profile_size' => '', + 'profile_image_url_large' => '', + 'utc_offset' => 0, + 'time_zone' => 'UTC', + 'geo_enabled' => false, + 'lang' => NULL, + 'contributors_enabled' => false, + 'is_translator' => false, + 'is_translation_enabled' => false, + 'following' => false, + 'follow_request_sent' => false, + 'statusnet_blocking' => false, + 'notifications' => false, + 'uid' => 42, + 'cid' => 44, + 'pid' => 45, + 'self' => 0, + 'network' => 'dfrn', + 'statusnet_profile_url' => 'http://localhost/profile/friendcontact', + ]; + + $result = ContactEndpointMock::list(Contact::SHARING, 42); + + $this->assertArrayHasKey('users', $result); + $this->assertContainsOnlyInstancesOf(User::class, $result['users']); + $this->assertSame($expectedFriendContactUser, $result['users'][0]->toArray()); + + $result = ContactEndpointMock::list([Contact::SHARING, Contact::FRIEND], 42); + + $this->assertArrayHasKey('users', $result); + $this->assertContainsOnlyInstancesOf(User::class, $result['users']); + $this->assertSame($expectedFriendContactUser, $result['users'][0]->toArray()); + } +}