1# -*- coding: utf-8 -*- 2import io 3import pytest 4import re 5 6from collections import namedtuple 7from unittest import mock 8 9from toot import console, User, App, http 10from toot.exceptions import ConsoleError 11 12from tests.utils import MockResponse 13 14app = App('habunek.com', 'https://habunek.com', 'foo', 'bar') 15user = User('habunek.com', 'ivan@habunek.com', 'xxx') 16 17MockUuid = namedtuple("MockUuid", ["hex"]) 18 19 20def uncolorize(text): 21 """Remove ANSI color sequences from a string""" 22 return re.sub(r'\x1b[^m]*m', '', text) 23 24 25def test_print_usage(capsys): 26 console.print_usage() 27 out, err = capsys.readouterr() 28 assert "toot - a Mastodon CLI client" in out 29 30 31@mock.patch('uuid.uuid4') 32@mock.patch('toot.http.post') 33def test_post_defaults(mock_post, mock_uuid, capsys): 34 mock_uuid.return_value = MockUuid("rock-on") 35 mock_post.return_value = MockResponse({ 36 'url': 'https://habunek.com/@ihabunek/1234567890' 37 }) 38 39 console.run_command(app, user, 'post', ['Hello world']) 40 41 mock_post.assert_called_once_with(app, user, '/api/v1/statuses', { 42 'status': 'Hello world', 43 'visibility': 'public', 44 'media_ids[]': [], 45 'sensitive': "false", 46 'spoiler_text': None, 47 'in_reply_to_id': None, 48 'language': None, 49 'scheduled_at': None, 50 }, headers={"Idempotency-Key": "rock-on"}) 51 52 out, err = capsys.readouterr() 53 assert 'Toot posted' in out 54 assert 'https://habunek.com/@ihabunek/1234567890' in out 55 assert not err 56 57 58@mock.patch('uuid.uuid4') 59@mock.patch('toot.http.post') 60def test_post_with_options(mock_post, mock_uuid, capsys): 61 mock_uuid.return_value = MockUuid("up-the-irons") 62 args = [ 63 'Hello world', 64 '--visibility', 'unlisted', 65 '--sensitive', 66 '--spoiler-text', 'Spoiler!', 67 '--reply-to', '123a', 68 '--language', 'hrv', 69 ] 70 71 mock_post.return_value = MockResponse({ 72 'url': 'https://habunek.com/@ihabunek/1234567890' 73 }) 74 75 console.run_command(app, user, 'post', args) 76 77 mock_post.assert_called_once_with(app, user, '/api/v1/statuses', { 78 'status': 'Hello world', 79 'media_ids[]': [], 80 'visibility': 'unlisted', 81 'sensitive': "true", 82 'spoiler_text': "Spoiler!", 83 'in_reply_to_id': '123a', 84 'language': 'hrv', 85 'scheduled_at': None, 86 }, headers={"Idempotency-Key": "up-the-irons"}) 87 88 out, err = capsys.readouterr() 89 assert 'Toot posted' in out 90 assert 'https://habunek.com/@ihabunek/1234567890' in out 91 assert not err 92 93 94def test_post_invalid_visibility(capsys): 95 args = ['Hello world', '--visibility', 'foo'] 96 97 with pytest.raises(SystemExit): 98 console.run_command(app, user, 'post', args) 99 100 out, err = capsys.readouterr() 101 assert "invalid visibility value: 'foo'" in err 102 103 104def test_post_invalid_media(capsys): 105 args = ['Hello world', '--media', 'does_not_exist.jpg'] 106 107 with pytest.raises(SystemExit): 108 console.run_command(app, user, 'post', args) 109 110 out, err = capsys.readouterr() 111 assert "can't open 'does_not_exist.jpg'" in err 112 113 114@mock.patch('toot.http.delete') 115def test_delete(mock_delete, capsys): 116 console.run_command(app, user, 'delete', ['12321']) 117 118 mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321') 119 120 out, err = capsys.readouterr() 121 assert 'Status deleted' in out 122 assert not err 123 124 125@mock.patch('toot.http.get') 126def test_timeline(mock_get, monkeypatch, capsys): 127 mock_get.return_value = MockResponse([{ 128 'id': '111111111111111111', 129 'account': { 130 'display_name': 'Frank Zappa ', 131 'acct': 'fz' 132 }, 133 'created_at': '2017-04-12T15:53:18.174Z', 134 'content': "<p>The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>", 135 'reblog': None, 136 'in_reply_to_id': None, 137 'media_attachments': [], 138 }]) 139 140 console.run_command(app, user, 'timeline', ['--once']) 141 142 mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None) 143 144 out, err = capsys.readouterr() 145 lines = out.split("\n") 146 147 assert "Frank Zappa " in lines[1] 148 assert "@fz" in lines[1] 149 assert "2017-04-12 15:53" in lines[1] 150 151 assert ( 152 "The computer can't tell you the emotional story. It can give you the " 153 "exact mathematical design, but\nwhat's missing is the eyebrows." in out) 154 155 assert "111111111111111111" in lines[-3] 156 157 assert err == "" 158 159 160@mock.patch('toot.http.get') 161def test_timeline_with_re(mock_get, monkeypatch, capsys): 162 mock_get.return_value = MockResponse([{ 163 'id': '111111111111111111', 164 'created_at': '2017-04-12T15:53:18.174Z', 165 'account': { 166 'display_name': 'Frank Zappa', 167 'acct': 'fz' 168 }, 169 'reblog': { 170 'account': { 171 'display_name': 'Johnny Cash', 172 'acct': 'jc' 173 }, 174 'content': "<p>The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>", 175 'media_attachments': [], 176 }, 177 'in_reply_to_id': '111111111111111110', 178 'media_attachments': [], 179 }]) 180 181 console.run_command(app, user, 'timeline', ['--once']) 182 183 mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None) 184 185 out, err = capsys.readouterr() 186 lines = out.split("\n") 187 188 assert "Frank Zappa" in lines[1] 189 assert "@fz" in lines[1] 190 assert "2017-04-12 15:53" in lines[1] 191 192 assert ( 193 "The computer can't tell you the emotional story. It can give you the " 194 "exact mathematical design, but\nwhat's missing is the eyebrows." in out) 195 196 assert "111111111111111111" in lines[-3] 197 assert "↻ Reblogged @jc" in lines[-3] 198 199 assert err == "" 200 201 202@mock.patch('toot.http.get') 203def test_thread(mock_get, monkeypatch, capsys): 204 mock_get.side_effect = [ 205 MockResponse({ 206 'id': '111111111111111111', 207 'account': { 208 'display_name': 'Frank Zappa', 209 'acct': 'fz' 210 }, 211 'created_at': '2017-04-12T15:53:18.174Z', 212 'content': "my response in the middle", 213 'reblog': None, 214 'in_reply_to_id': '111111111111111110', 215 'media_attachments': [], 216 }), 217 MockResponse({ 218 'ancestors': [{ 219 'id': '111111111111111110', 220 'account': { 221 'display_name': 'Frank Zappa', 222 'acct': 'fz' 223 }, 224 'created_at': '2017-04-12T15:53:18.174Z', 225 'content': "original content", 226 'media_attachments': [], 227 'reblog': None, 228 'in_reply_to_id': None}], 229 'descendants': [{ 230 'id': '111111111111111112', 231 'account': { 232 'display_name': 'Frank Zappa', 233 'acct': 'fz' 234 }, 235 'created_at': '2017-04-12T15:53:18.174Z', 236 'content': "response message", 237 'media_attachments': [], 238 'reblog': None, 239 'in_reply_to_id': '111111111111111111'}], 240 }), 241 ] 242 243 console.run_command(app, user, 'thread', ['111111111111111111']) 244 245 calls = [ 246 mock.call(app, user, '/api/v1/statuses/111111111111111111'), 247 mock.call(app, user, '/api/v1/statuses/111111111111111111/context'), 248 ] 249 mock_get.assert_has_calls(calls, any_order=False) 250 251 out, err = capsys.readouterr() 252 253 assert not err 254 255 # Display order 256 assert out.index('original content') < out.index('my response in the middle') 257 assert out.index('my response in the middle') < out.index('response message') 258 259 assert "original content" in out 260 assert "my response in the middle" in out 261 assert "response message" in out 262 assert "Frank Zappa" in out 263 assert "@fz" in out 264 assert "111111111111111111" in out 265 assert "In reply to" in out 266 267@mock.patch('toot.http.get') 268def test_reblogged_by(mock_get, monkeypatch, capsys): 269 mock_get.return_value = MockResponse([{ 270 'display_name': 'Terry Bozzio', 271 'acct': 'bozzio@drummers.social', 272 }, { 273 'display_name': 'Dweezil', 274 'acct': 'dweezil@zappafamily.social', 275 }]) 276 277 console.run_command(app, user, 'reblogged_by', ['111111111111111111']) 278 279 calls = [ 280 mock.call(app, user, '/api/v1/statuses/111111111111111111/reblogged_by'), 281 ] 282 mock_get.assert_has_calls(calls, any_order=False) 283 284 out, err = capsys.readouterr() 285 286 # Display order 287 expected = "\n".join([ 288 "Terry Bozzio", 289 " @bozzio@drummers.social", 290 "Dweezil", 291 " @dweezil@zappafamily.social", 292 "", 293 ]) 294 assert out == expected 295 296@mock.patch('toot.http.post') 297def test_upload(mock_post, capsys): 298 mock_post.return_value = MockResponse({ 299 'id': 123, 300 'url': 'https://bigfish.software/123/456', 301 'preview_url': 'https://bigfish.software/789/012', 302 'text_url': 'https://bigfish.software/345/678', 303 'type': 'image', 304 }) 305 306 console.run_command(app, user, 'upload', [__file__]) 307 308 mock_post.call_count == 1 309 310 args, kwargs = http.post.call_args 311 assert args == (app, user, '/api/v1/media') 312 assert isinstance(kwargs['files']['file'], io.BufferedReader) 313 314 out, err = capsys.readouterr() 315 assert "Uploading media" in out 316 assert __file__ in out 317 318 319@mock.patch('toot.http.get') 320def test_search(mock_get, capsys): 321 mock_get.return_value = MockResponse({ 322 'hashtags': [ 323 { 324 'history': [], 325 'name': 'foo', 326 'url': 'https://mastodon.social/tags/foo' 327 }, 328 { 329 'history': [], 330 'name': 'bar', 331 'url': 'https://mastodon.social/tags/bar' 332 }, 333 { 334 'history': [], 335 'name': 'baz', 336 'url': 'https://mastodon.social/tags/baz' 337 }, 338 ], 339 'accounts': [{ 340 'acct': 'thequeen', 341 'display_name': 'Freddy Mercury' 342 }, { 343 'acct': 'thequeen@other.instance', 344 'display_name': 'Mercury Freddy' 345 }], 346 'statuses': [], 347 }) 348 349 console.run_command(app, user, 'search', ['freddy']) 350 351 mock_get.assert_called_once_with(app, user, '/api/v2/search', { 352 'q': 'freddy', 353 'resolve': False, 354 }) 355 356 out, err = capsys.readouterr() 357 assert "Hashtags:\n#foo, #bar, #baz" in out 358 assert "Accounts:" in out 359 assert "@thequeen Freddy Mercury" in out 360 assert "@thequeen@other.instance Mercury Freddy" in out 361 362 363@mock.patch('toot.http.post') 364@mock.patch('toot.http.get') 365def test_follow(mock_get, mock_post, capsys): 366 mock_get.return_value = MockResponse([ 367 {'id': 123, 'acct': 'blixa@other.acc'}, 368 {'id': 321, 'acct': 'blixa'}, 369 ]) 370 mock_post.return_value = MockResponse() 371 372 console.run_command(app, user, 'follow', ['blixa']) 373 374 mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) 375 mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow') 376 377 out, err = capsys.readouterr() 378 assert "You are now following blixa" in out 379 380 381@mock.patch('toot.http.get') 382def test_follow_not_found(mock_get, capsys): 383 mock_get.return_value = MockResponse() 384 385 with pytest.raises(ConsoleError) as ex: 386 console.run_command(app, user, 'follow', ['blixa']) 387 388 mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) 389 assert "Account not found" == str(ex.value) 390 391 392@mock.patch('toot.http.post') 393@mock.patch('toot.http.get') 394def test_unfollow(mock_get, mock_post, capsys): 395 mock_get.return_value = MockResponse([ 396 {'id': 123, 'acct': 'blixa@other.acc'}, 397 {'id': 321, 'acct': 'blixa'}, 398 ]) 399 400 mock_post.return_value = MockResponse() 401 402 console.run_command(app, user, 'unfollow', ['blixa']) 403 404 mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) 405 mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow') 406 407 out, err = capsys.readouterr() 408 assert "You are no longer following blixa" in out 409 410 411@mock.patch('toot.http.get') 412def test_unfollow_not_found(mock_get, capsys): 413 mock_get.return_value = MockResponse([]) 414 415 with pytest.raises(ConsoleError) as ex: 416 console.run_command(app, user, 'unfollow', ['blixa']) 417 418 mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) 419 420 assert "Account not found" == str(ex.value) 421 422 423@mock.patch('toot.http.get') 424def test_whoami(mock_get, capsys): 425 mock_get.return_value = MockResponse({ 426 'acct': 'ihabunek', 427 'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', 428 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', 429 'created_at': '2017-04-04T13:23:09.777Z', 430 'display_name': 'Ivan Habunek', 431 'followers_count': 5, 432 'following_count': 9, 433 'header': '/headers/original/missing.png', 434 'header_static': '/headers/original/missing.png', 435 'id': 46103, 436 'locked': False, 437 'note': 'A developer.', 438 'statuses_count': 19, 439 'url': 'https://mastodon.social/@ihabunek', 440 'username': 'ihabunek' 441 }) 442 443 console.run_command(app, user, 'whoami', []) 444 445 mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials') 446 447 out, err = capsys.readouterr() 448 out = uncolorize(out) 449 450 assert "@ihabunek Ivan Habunek" in out 451 assert "A developer." in out 452 assert "https://mastodon.social/@ihabunek" in out 453 assert "ID: 46103" in out 454 assert "Since: 2017-04-04 @ 13:23:09" in out 455 assert "Followers: 5" in out 456 assert "Following: 9" in out 457 assert "Statuses: 19" in out 458 459 460@mock.patch('toot.http.get') 461def test_notifications(mock_get, capsys): 462 mock_get.return_value = MockResponse([{ 463 'id': '1', 464 'type': 'follow', 465 'created_at': '2019-02-16T07:01:20.714Z', 466 'account': { 467 'display_name': 'Frank Zappa', 468 'acct': 'frank@zappa.social', 469 }, 470 }, { 471 'id': '2', 472 'type': 'mention', 473 'created_at': '2017-01-12T12:12:12.0Z', 474 'account': { 475 'display_name': 'Dweezil Zappa', 476 'acct': 'dweezil@zappa.social', 477 }, 478 'status': { 479 'id': '111111111111111111', 480 'account': { 481 'display_name': 'Dweezil Zappa', 482 'acct': 'dweezil@zappa.social', 483 }, 484 'created_at': '2017-04-12T15:53:18.174Z', 485 'content': "<p>We still have fans in 2017 @fan123</p>", 486 'reblog': None, 487 'in_reply_to_id': None, 488 'media_attachments': [], 489 }, 490 }, { 491 'id': '3', 492 'type': 'reblog', 493 'created_at': '1983-11-03T03:03:03.333Z', 494 'account': { 495 'display_name': 'Terry Bozzio', 496 'acct': 'terry@bozzio.social', 497 }, 498 'status': { 499 'id': '1234', 500 'account': { 501 'display_name': 'Zappa Fan', 502 'acct': 'fan123@zappa-fans.social' 503 }, 504 'created_at': '1983-11-04T15:53:18.174Z', 505 'content': "<p>The Black Page, a masterpiece</p>", 506 'reblog': None, 507 'in_reply_to_id': None, 508 'media_attachments': [], 509 }, 510 }, { 511 'id': '4', 512 'type': 'favourite', 513 'created_at': '1983-12-13T01:02:03.444Z', 514 'account': { 515 'display_name': 'Zappa Old Fan', 516 'acct': 'fan9@zappa-fans.social', 517 }, 518 'status': { 519 'id': '1234', 520 'account': { 521 'display_name': 'Zappa Fan', 522 'acct': 'fan123@zappa-fans.social' 523 }, 524 'created_at': '1983-11-04T15:53:18.174Z', 525 'content': "<p>The Black Page, a masterpiece</p>", 526 'reblog': None, 527 'in_reply_to_id': None, 528 'media_attachments': [], 529 }, 530 }]) 531 532 console.run_command(app, user, 'notifications', []) 533 534 mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20}) 535 536 out, err = capsys.readouterr() 537 out = uncolorize(out) 538 539 width = 100 540 assert not err 541 assert out == "\n".join([ 542 "─" * width, 543 "Frank Zappa @frank@zappa.social now follows you", 544 "─" * width, 545 "Dweezil Zappa @dweezil@zappa.social mentioned you in", 546 "Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53", 547 "", 548 "We still have fans in 2017 @fan123", 549 "", 550 "ID 111111111111111111 ", 551 "─" * width, 552 "Terry Bozzio @terry@bozzio.social reblogged your status", 553 "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53", 554 "", 555 "The Black Page, a masterpiece", 556 "", 557 "ID 1234 ", 558 "─" * width, 559 "Zappa Old Fan @fan9@zappa-fans.social favourited your status", 560 "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53", 561 "", 562 "The Black Page, a masterpiece", 563 "", 564 "ID 1234 ", 565 "─" * width, 566 "", 567 ]) 568 569 570@mock.patch('toot.http.get') 571def test_notifications_empty(mock_get, capsys): 572 mock_get.return_value = MockResponse([]) 573 574 console.run_command(app, user, 'notifications', []) 575 576 mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20}) 577 578 out, err = capsys.readouterr() 579 out = uncolorize(out) 580 581 assert not err 582 assert out == "No notification\n" 583 584 585@mock.patch('toot.http.post') 586def test_notifications_clear(mock_post, capsys): 587 console.run_command(app, user, 'notifications', ['--clear']) 588 out, err = capsys.readouterr() 589 out = uncolorize(out) 590 591 mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear') 592 assert not err 593 assert out == 'Cleared notifications\n' 594 595 596def u(user_id, access_token="abc"): 597 username, instance = user_id.split("@") 598 return { 599 "instance": instance, 600 "username": username, 601 "access_token": access_token, 602 } 603 604 605@mock.patch('toot.config.save_config') 606@mock.patch('toot.config.load_config') 607def test_logout(mock_load, mock_save, capsys): 608 mock_load.return_value = { 609 "users": { 610 "king@gizzard.social": u("king@gizzard.social"), 611 "lizard@wizard.social": u("lizard@wizard.social"), 612 }, 613 "active_user": "king@gizzard.social", 614 } 615 616 console.run_command(app, user, "logout", ["king@gizzard.social"]) 617 618 mock_save.assert_called_once_with({ 619 'users': { 620 'lizard@wizard.social': u("lizard@wizard.social") 621 }, 622 'active_user': None 623 }) 624 625 out, err = capsys.readouterr() 626 assert "✓ User king@gizzard.social logged out" in out 627 628 629@mock.patch('toot.config.save_config') 630@mock.patch('toot.config.load_config') 631def test_activate(mock_load, mock_save, capsys): 632 mock_load.return_value = { 633 "users": { 634 "king@gizzard.social": u("king@gizzard.social"), 635 "lizard@wizard.social": u("lizard@wizard.social"), 636 }, 637 "active_user": "king@gizzard.social", 638 } 639 640 console.run_command(app, user, "activate", ["lizard@wizard.social"]) 641 642 mock_save.assert_called_once_with({ 643 'users': { 644 "king@gizzard.social": u("king@gizzard.social"), 645 'lizard@wizard.social': u("lizard@wizard.social") 646 }, 647 'active_user': "lizard@wizard.social" 648 }) 649 650 out, err = capsys.readouterr() 651 assert "✓ User lizard@wizard.social active" in out 652