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&apos;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&apos;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