diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 366e6ab..8fcd1cb 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,6 +13,7 @@ export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development" ``` """ +import json import re import os import psycopg2 @@ -94,6 +95,16 @@ def friend(app): return register_account(app) +@pytest.fixture(scope="session") +def user_id(app, user): + return api.find_account(app, user, user.username)["id"] + + +@pytest.fixture(scope="session") +def friend_id(app, user, friend): + return api.find_account(app, user, friend.username)["id"] + + @pytest.fixture def run(app, user, capsys): def _run(command, *params, as_user=None): @@ -110,6 +121,14 @@ def run(app, user, capsys): return _run +@pytest.fixture +def run_json(run): + def _run_json(command, *params): + out = run(command, *params) + return json.loads(out) + return _run_json + + @pytest.fixture def run_anon(capsys): def _run(command, *params): diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index fe18def..9f5b4ae 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -1,21 +1,22 @@ import json -from toot.entities import Account, from_dict +from toot import App, User, api +from toot.entities import Account, Relationship, from_dict -def test_whoami(user, run): +def test_whoami(user: User, run): out = run("whoami") # TODO: test other fields once updating account is supported assert f"@{user.username}" in out -def test_whoami_json(user, run): +def test_whoami_json(user: User, run): out = run("whoami", "--json") account = from_dict(Account, json.loads(out)) assert account.username == user.username -def test_whois(app, friend, run): +def test_whois(app: App, friend: User, run): variants = [ friend.username, f"@{friend.username}", @@ -26,3 +27,183 @@ def test_whois(app, friend, run): for username in variants: out = run("whois", username) assert f"@{friend.username}" in out + + +def test_following(app: App, user: User, friend: User, friend_id, run): + # Make sure we're not initally following friend + api.unfollow(app, user, friend_id) + + out = run("following", user.username) + assert out == "" + + out = run("follow", friend.username) + assert out == f"✓ You are now following {friend.username}" + + out = run("following", user.username) + assert friend.username in out + + out = run("unfollow", friend.username) + assert out == f"✓ You are no longer following {friend.username}" + + out = run("following", user.username) + assert out == "" + + +def test_following_case_insensitive(user: User, friend: User, run): + assert friend.username != friend.username.upper() + out = run("follow", friend.username.upper()) + assert out == f"✓ You are now following {friend.username.upper()}" + + +def test_following_not_found(run): + out = run("follow", "bananaman") + assert out == "Account not found" + + out = run("unfollow", "bananaman") + assert out == "Account not found" + + +def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json): + # Make sure we're not initally following friend + api.unfollow(app, user, friend_id) + + result = run_json("following", user.username, "--json") + assert result == [] + + result = run_json("followers", friend.username, "--json") + assert result == [] + + result = run_json("follow", friend.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + assert relationship.following is True + + [result] = run_json("following", user.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + + [result] = run_json("followers", friend.username, "--json") + assert result["id"] == user_id + + result = run_json("unfollow", friend.username, "--json") + assert result["id"] == friend_id + assert result["following"] is False + + result = run_json("following", user.username, "--json") + assert result == [] + + result = run_json("followers", friend.username, "--json") + assert result == [] + + +def test_mute(app, user, friend, friend_id, run): + # Make sure we're not initially muting friend + api.unmute(app, user, friend_id) + + out = run("muted") + assert out == "No accounts muted" + + out = run("mute", friend.username) + assert out == f"✓ You have muted {friend.username}" + + out = run("muted") + assert friend.username in out + + out = run("unmute", friend.username) + assert out == f"✓ {friend.username} is no longer muted" + + out = run("muted") + assert out == "No accounts muted" + + +def test_mute_case_insensitive(friend: User, run): + out = run("mute", friend.username.upper()) + assert out == f"✓ You have muted {friend.username.upper()}" + + +def test_mute_not_found(run): + out = run("mute", "doesnotexistperson") + assert out == f"Account not found" + + out = run("unmute", "doesnotexistperson") + assert out == f"Account not found" + + +def test_mute_json(app: App, user: User, friend: User, run_json, friend_id): + # Make sure we're not initially muting friend + api.unmute(app, user, friend_id) + + result = run_json("muted", "--json") + assert result == [] + + result = run_json("mute", friend.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + assert relationship.muting is True + + [result] = run_json("muted", "--json") + account = from_dict(Account, result) + assert account.id == friend_id + + result = run_json("unmute", friend.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + assert relationship.muting is False + + result = run_json("muted", "--json") + assert result == [] + + +def test_block(app, user, friend, friend_id, run): + # Make sure we're not initially blocking friend + api.unblock(app, user, friend_id) + + out = run("blocked") + assert out == "No accounts blocked" + + out = run("block", friend.username) + assert out == f"✓ You are now blocking {friend.username}" + + out = run("blocked") + assert friend.username in out + + out = run("unblock", friend.username) + assert out == f"✓ {friend.username} is no longer blocked" + + out = run("blocked") + assert out == "No accounts blocked" + + +def test_block_case_insensitive(friend: User, run): + out = run("block", friend.username.upper()) + assert out == f"✓ You are now blocking {friend.username.upper()}" + + +def test_block_not_found(run): + out = run("block", "doesnotexistperson") + assert out == f"Account not found" + + +def test_block_json(app: App, user: User, friend: User, run_json, friend_id): + # Make sure we're not initially blocking friend + api.unblock(app, user, friend_id) + + result = run_json("blocked", "--json") + assert result == [] + + result = run_json("block", friend.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + assert relationship.blocking is True + + [result] = run_json("blocked", "--json") + account = from_dict(Account, result) + assert account.id == friend_id + + result = run_json("unblock", friend.username, "--json") + relationship = from_dict(Relationship, result) + assert relationship.id == friend_id + assert relationship.blocking is False + + result = run_json("blocked", "--json") + assert result == [] diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index a6855e0..67e7783 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -1,8 +1,10 @@ import json +from pprint import pprint import pytest import re from toot import api +from toot.entities import Account, from_dict_list from toot.exceptions import ConsoleError from uuid import uuid4 @@ -58,6 +60,12 @@ def test_search_account(friend, run): assert out == f"Accounts:\n* @{friend.username}" +def test_search_account_json(friend, run_json): + out = run_json("search", friend.username, "--json") + [account] = from_dict_list(Account, out["accounts"]) + assert account.acct == friend.username + + def test_search_hashtag(app, user, run): api.post_status(app, user, "#hashtag_x") api.post_status(app, user, "#hashtag_y") @@ -67,6 +75,19 @@ def test_search_hashtag(app, user, run): assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z" +def test_search_hashtag_json(app, user, run_json): + api.post_status(app, user, "#hashtag_x") + api.post_status(app, user, "#hashtag_y") + api.post_status(app, user, "#hashtag_z") + + out = run_json("search", "#hashtag", "--json") + [h1, h2, h3] = sorted(out["hashtags"], key=lambda h: h["name"]) + + assert h1["name"] == "hashtag_x" + assert h2["name"] == "hashtag_y" + assert h3["name"] == "hashtag_z" + + def test_tags(run, base_url): out = run("tags_followed") assert out == "You're not following any hashtags." diff --git a/tests/test_console.py b/tests/test_console.py index d9859df..028e836 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -218,136 +218,6 @@ def test_upload(mock_post, capsys): assert __file__ in out -@mock.patch('toot.http.get') -def test_search(mock_get, capsys): - mock_get.return_value = MockResponse({ - 'hashtags': [ - { - 'history': [], - 'name': 'foo', - 'url': 'https://mastodon.social/tags/foo' - }, - { - 'history': [], - 'name': 'bar', - 'url': 'https://mastodon.social/tags/bar' - }, - { - 'history': [], - 'name': 'baz', - 'url': 'https://mastodon.social/tags/baz' - }, - ], - 'accounts': [{ - 'acct': 'thequeen', - 'display_name': 'Freddy Mercury' - }, { - 'acct': 'thequeen@other.instance', - 'display_name': 'Mercury Freddy' - }], - 'statuses': [], - }) - - console.run_command(app, user, 'search', ['freddy']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', { - 'q': 'freddy', - 'type': None, - 'resolve': False, - }) - - out, err = capsys.readouterr() - out = uncolorize(out) - assert "Hashtags:\n#foo, #bar, #baz" in out - assert "Accounts:" in out - assert "@thequeen Freddy Mercury" in out - assert "@thequeen@other.instance Mercury Freddy" in out - - -@mock.patch('toot.http.post') -@mock.patch('toot.http.get') -def test_follow(mock_get, mock_post, capsys): - mock_get.return_value = MockResponse({ - "accounts": [ - {"id": 123, "acct": "blixa@other.acc"}, - {"id": 321, "acct": "blixa"}, - ] - }) - mock_post.return_value = MockResponse() - - console.run_command(app, user, 'follow', ['blixa']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True}) - mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow') - - out, err = capsys.readouterr() - assert "You are now following blixa" in out - - -@mock.patch('toot.http.post') -@mock.patch('toot.http.get') -def test_follow_case_insensitive(mock_get, mock_post, capsys): - mock_get.return_value = MockResponse({ - "accounts": [ - {"id": 123, "acct": "blixa@other.acc"}, - {"id": 321, "acct": "blixa"}, - ] - }) - mock_post.return_value = MockResponse() - - console.run_command(app, user, 'follow', ['bLiXa@oThEr.aCc']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'bLiXa@oThEr.aCc', 'type': 'accounts', 'resolve': True}) - mock_post.assert_called_once_with(app, user, '/api/v1/accounts/123/follow') - - out, err = capsys.readouterr() - assert "You are now following bLiXa@oThEr.aCc" in out - - -@mock.patch('toot.http.get') -def test_follow_not_found(mock_get, capsys): - mock_get.return_value = MockResponse({"accounts": []}) - - with pytest.raises(ConsoleError) as ex: - console.run_command(app, user, 'follow', ['blixa']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True}) - assert "Account not found" == str(ex.value) - - -@mock.patch('toot.http.post') -@mock.patch('toot.http.get') -def test_unfollow(mock_get, mock_post, capsys): - mock_get.return_value = MockResponse({ - "accounts": [ - {'id': 123, 'acct': 'blixa@other.acc'}, - {'id': 321, 'acct': 'blixa'}, - ] - }) - - mock_post.return_value = MockResponse() - - console.run_command(app, user, 'unfollow', ['blixa']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True}) - mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow') - - out, err = capsys.readouterr() - assert "You are no longer following blixa" in out - - -@mock.patch('toot.http.get') -def test_unfollow_not_found(mock_get, capsys): - mock_get.return_value = MockResponse({"accounts": []}) - - with pytest.raises(ConsoleError) as ex: - console.run_command(app, user, 'unfollow', ['blixa']) - - mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True}) - - assert "Account not found" == str(ex.value) - - @mock.patch('toot.http.get') def test_whoami(mock_get, capsys): mock_get.return_value = MockResponse({ diff --git a/toot/api.py b/toot/api.py index 2158b13..b2e82b7 100644 --- a/toot/api.py +++ b/toot/api.py @@ -38,9 +38,9 @@ def find_account(app, user, account_name): raise ConsoleError("Account not found") -def _account_action(app, user, account, action): +def _account_action(app, user, account, action) -> Response: url = f"/api/v1/accounts/{account}/{action}" - return http.post(app, user, url).json() + return http.post(app, user, url) def _status_action(app, user, status_id, action, data=None) -> Response: diff --git a/toot/commands.py b/toot/commands.py index 285258b..ea96964 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -5,13 +5,14 @@ import platform from datetime import datetime, timedelta, timezone from time import sleep, time + from toot import api, config, __version__ from toot.auth import login_interactive, login_browser_interactive, create_app_interactive from toot.entities import Account, Instance, Notification, Status, from_dict from toot.exceptions import ApiError, ConsoleError from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list, - print_search_results, print_status, print_table, print_timeline, print_notifications, print_tag_list, - print_list_accounts, print_user_list) + print_search_results, print_status, print_table, print_timeline, print_notifications, + print_tag_list, print_list_accounts, print_user_list) from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY from toot.utils.datetime import parse_datetime @@ -418,26 +419,38 @@ def _do_upload(app, user, file, description, thumbnail): def follow(app, user, args): account = api.find_account(app, user, args.account) - api.follow(app, user, account['id']) - print_out("✓ You are now following {}".format(args.account)) + response = api.follow(app, user, account["id"]) + if args.json: + print(response.text) + else: + print_out(f"✓ You are now following {args.account}") def unfollow(app, user, args): account = api.find_account(app, user, args.account) - api.unfollow(app, user, account['id']) - print_out("✓ You are no longer following {}".format(args.account)) + response = api.unfollow(app, user, account["id"]) + if args.json: + print(response.text) + else: + print_out(f"✓ You are no longer following {args.account}") def following(app, user, args): account = api.find_account(app, user, args.account) - response = api.following(app, user, account['id']) - print_acct_list(response) + accounts = api.following(app, user, account["id"]) + if args.json: + print(json.dumps(accounts)) + else: + print_acct_list(accounts) def followers(app, user, args): account = api.find_account(app, user, args.account) - response = api.followers(app, user, account['id']) - print_acct_list(response) + accounts = api.followers(app, user, account["id"]) + if args.json: + print(json.dumps(accounts)) + else: + print_acct_list(accounts) def tags_follow(app, user, args): @@ -524,36 +537,62 @@ def _get_list_id(app, user, args): def mute(app, user, args): account = api.find_account(app, user, args.account) - api.mute(app, user, account['id']) - print_out("✓ You have muted {}".format(args.account)) + response = api.mute(app, user, account['id']) + if args.json: + print(response.text) + else: + print_out("✓ You have muted {}".format(args.account)) def unmute(app, user, args): account = api.find_account(app, user, args.account) - api.unmute(app, user, account['id']) - print_out("✓ {} is no longer muted".format(args.account)) + response = api.unmute(app, user, account['id']) + if args.json: + print(response.text) + else: + print_out("✓ {} is no longer muted".format(args.account)) def muted(app, user, args): response = api.muted(app, user) - print_acct_list(response) + if args.json: + print(json.dumps(response)) + else: + if len(response) > 0: + print("Muted accounts:") + print_acct_list(response) + else: + print("No accounts muted") def block(app, user, args): account = api.find_account(app, user, args.account) - api.block(app, user, account['id']) - print_out("✓ You are now blocking {}".format(args.account)) + response = api.block(app, user, account['id']) + if args.json: + print(response.text) + else: + print_out("✓ You are now blocking {}".format(args.account)) def unblock(app, user, args): account = api.find_account(app, user, args.account) - api.unblock(app, user, account['id']) - print_out("✓ {} is no longer blocked".format(args.account)) + response = api.unblock(app, user, account['id']) + if args.json: + print(response.text) + else: + print_out("✓ {} is no longer blocked".format(args.account)) def blocked(app, user, args): response = api.blocked(app, user) - print_acct_list(response) + if args.json: + print(json.dumps(response)) + else: + if len(response) > 0: + print("Blocked accounts:") + print_acct_list(response) + else: + print("No accounts blocked") def whoami(app, user, args): diff --git a/toot/console.py b/toot/console.py index 9f74557..3f16dce 100644 --- a/toot/console.py +++ b/toot/console.py @@ -671,77 +671,61 @@ ACCOUNTS_COMMANDS = [ Command( name="follow", description="Follow an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="unfollow", description="Unfollow an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="following", description="List accounts followed by the given account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="followers", description="List accounts following the given account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="mute", description="Mute an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="unmute", description="Unmute an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="muted", description="List muted accounts", - arguments=[], + arguments=[json_arg], require_auth=True, ), Command( name="block", description="Block an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="unblock", description="Unblock an account", - arguments=[ - account_arg, - ], + arguments=[account_arg, json_arg], require_auth=True, ), Command( name="blocked", description="List blocked accounts", - arguments=[], + arguments=[json_arg], require_auth=True, ), ] diff --git a/toot/entities.py b/toot/entities.py index 2d5279c..17f7406 100644 --- a/toot/entities.py +++ b/toot/entities.py @@ -384,6 +384,29 @@ class Instance: rules: List[Rule] +@dataclass +class Relationship: + """ + Represents the relationship between accounts, such as following / blocking / + muting / etc. + https://docs.joinmastodon.org/entities/Relationship/ + """ + id: str + following: bool + showing_reblogs: bool + notifying: bool + languages: List[str] + followed_by: bool + blocking: bool + blocked_by: bool + muting: bool + muting_notifications: bool + requested: bool + domain_blocking: bool + endorsed: bool + note: str + + # Generic data class instance T = TypeVar("T") @@ -422,6 +445,10 @@ def from_dict(cls: Type[T], data: Dict) -> T: return cls(**dict(_fields())) +def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]: + return [from_dict(cls, x) for x in data] + + def _get_default_value(field): if field.default is not dataclasses.MISSING: return field.default diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 58eb457..eb89814 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -330,17 +330,17 @@ def take_action(button: Button, self: Account): action = button.get_label() if action == "Confirm Follow": - self.relationship = api.follow(self.app, self.user, self.account["id"]) + self.relationship = api.follow(self.app, self.user, self.account["id"]).json() elif action == "Confirm Unfollow": - self.relationship = api.unfollow(self.app, self.user, self.account["id"]) + self.relationship = api.unfollow(self.app, self.user, self.account["id"]).json() elif action == "Confirm Mute": - self.relationship = api.mute(self.app, self.user, self.account["id"]) + self.relationship = api.mute(self.app, self.user, self.account["id"]).json() elif action == "Confirm Unmute": - self.relationship = api.unmute(self.app, self.user, self.account["id"]) + self.relationship = api.unmute(self.app, self.user, self.account["id"]).json() elif action == "Confirm Block": - self.relationship = api.block(self.app, self.user, self.account["id"]) + self.relationship = api.block(self.app, self.user, self.account["id"]).json() elif action == "Confirm Unblock": - self.relationship = api.unblock(self.app, self.user, self.account["id"]) + self.relationship = api.unblock(self.app, self.user, self.account["id"]).json() self.last_action = None self.setup_listbox()