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()