Cleanup tests code
[python-musicpd.git] / test.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 # pylint: disable=missing-docstring
4 """
5 Test suite highly borrowed^Wsteal from python-mpd2 [0] project.
6
7 [0] https://github.com/Mic92/python-mpd2
8 """
9
10
11 import itertools
12 import os
13 import types
14 import unittest
15 import unittest.mock as mock
16 import warnings
17
18 import musicpd
19
20
21 # show deprecation warnings
22 warnings.simplefilter('default')
23
24
25 TEST_MPD_HOST, TEST_MPD_PORT = ('example.com', 10000)
26
27
28 class testEnvVar(unittest.TestCase):
29
30     def test_envvar(self):
31         # mock "os.path.exists" here to ensure there are no socket in
32         # XDG_RUNTIME_DIR/mpd or /run/mpd since with test defaults fallbacks
33         # when :
34         #   * neither MPD_HOST nor XDG_RUNTIME_DIR are not set
35         #   * /run/mpd does not expose a socket
36         with mock.patch('os.path.exists', return_value=False):
37             os.environ.pop('MPD_HOST', None)
38             os.environ.pop('MPD_PORT', None)
39             client = musicpd.MPDClient()
40             self.assertEqual(client.host, 'localhost')
41             self.assertEqual(client.port, '6600')
42
43             os.environ.pop('MPD_HOST', None)
44             os.environ['MPD_PORT'] = '6666'
45             client = musicpd.MPDClient()
46             self.assertEqual(client.pwd, None)
47             self.assertEqual(client.host, 'localhost')
48             self.assertEqual(client.port, '6666')
49
50         # Test password extraction
51         os.environ['MPD_HOST'] = 'pa55w04d@example.org'
52         client = musicpd.MPDClient()
53         self.assertEqual(client.pwd, 'pa55w04d')
54         self.assertEqual(client.host, 'example.org')
55
56         # Test host alone
57         os.environ['MPD_HOST'] = 'example.org'
58         client = musicpd.MPDClient()
59         self.assertFalse(client.pwd)
60         self.assertEqual(client.host, 'example.org')
61
62         # Test password extraction (no host)
63         os.environ['MPD_HOST'] = 'pa55w04d@'
64         with mock.patch('os.path.exists', return_value=False):
65             client = musicpd.MPDClient()
66         self.assertEqual(client.pwd, 'pa55w04d')
67         self.assertEqual(client.host, 'localhost')
68
69         # Test badly formatted MPD_HOST
70         os.environ['MPD_HOST'] = '@'
71         with mock.patch('os.path.exists', return_value=False):
72             client = musicpd.MPDClient()
73         self.assertEqual(client.pwd, None)
74         self.assertEqual(client.host, 'localhost')
75
76         # Test unix socket extraction
77         os.environ['MPD_HOST'] = 'pa55w04d@/unix/sock'
78         client = musicpd.MPDClient()
79         self.assertEqual(client.host, '/unix/sock')
80
81         # Test plain abstract socket extraction
82         os.environ['MPD_HOST'] = '@abstract'
83         client = musicpd.MPDClient()
84         self.assertEqual(client.host, '@abstract')
85
86         # Test password and abstract socket extraction
87         os.environ['MPD_HOST'] = 'pass@@abstract'
88         client = musicpd.MPDClient()
89         self.assertEqual(client.pwd, 'pass')
90         self.assertEqual(client.host, '@abstract')
91
92         # Test unix socket fallback
93         os.environ.pop('MPD_HOST', None)
94         os.environ.pop('MPD_PORT', None)
95         os.environ.pop('XDG_RUNTIME_DIR', None)
96         with mock.patch('os.path.exists', return_value=True):
97             client = musicpd.MPDClient()
98             self.assertEqual(client.host, '/run/mpd/socket')
99             os.environ['XDG_RUNTIME_DIR'] = '/run/user/1000'
100             client = musicpd.MPDClient()
101             self.assertEqual(client.host, '/run/user/1000/mpd/socket')
102
103         os.environ.pop('MPD_HOST', None)
104         os.environ.pop('MPD_PORT', None)
105         os.environ['XDG_RUNTIME_DIR'] = '/run/user/1000/'
106         with mock.patch('os.path.exists', return_value=True):
107             client = musicpd.MPDClient()
108             self.assertEqual(client.host, '/run/user/1000/mpd/socket')
109
110         # Test MPD_TIMEOUT
111         os.environ.pop('MPD_TIMEOUT', None)
112         client = musicpd.MPDClient()
113         self.assertEqual(client.mpd_timeout, musicpd.CONNECTION_TIMEOUT)
114         os.environ['MPD_TIMEOUT'] = 'garbage'
115         client = musicpd.MPDClient()
116         self.assertEqual(client.mpd_timeout,
117                          musicpd.CONNECTION_TIMEOUT,
118                          'Garbage is silently ignore to use default value')
119         os.environ['MPD_TIMEOUT'] = '42'
120         client = musicpd.MPDClient()
121         self.assertEqual(client.mpd_timeout, 42)
122
123
124 class TestMPDClient(unittest.TestCase):
125
126     longMessage = True
127     # last sync: musicpd 0.6.0 unreleased / Fri Feb 19 15:34:53 CET 2021
128     commands = {
129             # Status Commands
130             'clearerror':         'nothing',
131             'currentsong':        'object',
132             'idle':               'list',
133             'noidle':             None,
134             'status':             'object',
135             'stats':              'object',
136             # Playback Option Commands
137             'consume':            'nothing',
138             'crossfade':          'nothing',
139             'mixrampdb':          'nothing',
140             'mixrampdelay':       'nothing',
141             'random':             'nothing',
142             'repeat':             'nothing',
143             'setvol':             'nothing',
144             'getvol':             'object',
145             'single':             'nothing',
146             'replay_gain_mode':   'nothing',
147             'replay_gain_status': 'item',
148             'volume':             'nothing',
149             # Playback Control Commands
150             'next':               'nothing',
151             'pause':              'nothing',
152             'play':               'nothing',
153             'playid':             'nothing',
154             'previous':           'nothing',
155             'seek':               'nothing',
156             'seekid':             'nothing',
157             'seekcur':            'nothing',
158             'stop':               'nothing',
159             # Queue Commands
160             'add':                'nothing',
161             'addid':              'item',
162             'clear':              'nothing',
163             'delete':             'nothing',
164             'deleteid':           'nothing',
165             'move':               'nothing',
166             'moveid':             'nothing',
167             'playlist':           'playlist',
168             'playlistfind':       'songs',
169             'playlistid':         'songs',
170             'playlistinfo':       'songs',
171             'playlistsearch':     'songs',
172             'plchanges':          'songs',
173             'plchangesposid':     'changes',
174             'prio':               'nothing',
175             'prioid':             'nothing',
176             'rangeid':            'nothing',
177             'shuffle':            'nothing',
178             'swap':               'nothing',
179             'swapid':             'nothing',
180             'addtagid':           'nothing',
181             'cleartagid':         'nothing',
182             # Stored Playlist Commands
183             'listplaylist':       'list',
184             'listplaylistinfo':   'songs',
185             'listplaylists':      'playlists',
186             'load':               'nothing',
187             'playlistadd':        'nothing',
188             'playlistclear':      'nothing',
189             'playlistdelete':     'nothing',
190             'playlistmove':       'nothing',
191             'rename':             'nothing',
192             'rm':                 'nothing',
193             'save':               'nothing',
194             # Database Commands
195             'albumart':           'composite',
196             'count':              'object',
197             'getfingerprint':     'object',
198             'find':               'songs',
199             'findadd':            'nothing',
200             'list':               'list',
201             'listall':            'database',
202             'listallinfo':        'database',
203             'listfiles':          'database',
204             'lsinfo':             'database',
205             'readcomments':       'object',
206             'readpicture':        'composite',
207             'search':             'songs',
208             'searchadd':          'nothing',
209             'searchaddpl':        'nothing',
210             'update':             'item',
211             'rescan':             'item',
212             # Mounts and neighbors
213             'mount':              'nothing',
214             'unmount':            'nothing',
215             'listmounts':         'mounts',
216             'listneighbors':      'neighbors',
217             # Sticker Commands
218             'sticker get':        'item',
219             'sticker set':        'nothing',
220             'sticker delete':     'nothing',
221             'sticker list':       'list',
222             'sticker find':       'songs',
223             # Connection Commands
224             'close':              None,
225             'kill':               None,
226             'password':           'nothing',
227             'ping':               'nothing',
228             'binarylimit':        'nothing',
229             'tagtypes':           'list',
230             'tagtypes disable':   'nothing',
231             'tagtypes enable':    'nothing',
232             'tagtypes clear':     'nothing',
233             'tagtypes all':       'nothing',
234             # Partition Commands
235             'partition':          'nothing',
236             'listpartitions':     'list',
237             'newpartition':       'nothing',
238             'delpartition':       'nothing',
239             'moveoutput':         'nothing',
240             # Audio Output Commands
241             'disableoutput':      'nothing',
242             'enableoutput':       'nothing',
243             'toggleoutput':       'nothing',
244             'outputs':            'outputs',
245             'outputset':          'nothing',
246             # Reflection Commands
247             'config':             'object',
248             'commands':           'list',
249             'notcommands':        'list',
250             'urlhandlers':        'list',
251             'decoders':           'plugins',
252             # Client to Client
253             'subscribe':          'nothing',
254             'unsubscribe':        'nothing',
255             'channels':           'list',
256             'readmessages':       'messages',
257             'sendmessage':        'nothing',
258         }
259
260     def setUp(self):
261         self.socket_patch = mock.patch('musicpd.socket')
262         self.socket_mock = self.socket_patch.start()
263         self.socket_mock.getaddrinfo.return_value = [range(5)]
264
265         self.socket_mock.socket.side_effect = (
266             lambda *a, **kw:
267             # Create a new socket.socket() mock with default attributes,
268             # each time we are calling it back (otherwise, it keeps set
269             # attributes across calls).
270             # That's probably what we want, since reconnecting is like
271             # reinitializing the entire connection, and so, the mock.
272             mock.MagicMock(name='socket.socket'))
273
274         self.client = musicpd.MPDClient()
275         self.client.connect(TEST_MPD_HOST, TEST_MPD_PORT)
276         self.client._sock.reset_mock()
277         self.MPDWillReturn('ACK don\'t forget to setup your mock\n')
278
279     def tearDown(self):
280         self.socket_patch.stop()
281
282     def MPDWillReturn(self, *lines):
283         # Return what the caller wants first, then do as if the socket was
284         # disconnected.
285         self.client._rfile.readline.side_effect = itertools.chain(
286             lines, itertools.repeat(''))
287
288     def MPDWillReturnBinary(self, lines):
289         data = bytearray(b''.join(lines))
290
291         def read(amount):
292             val = bytearray()
293             while amount > 0:
294                 amount -= 1
295                 # _ = data.pop(0)
296                 # print(hex(_))
297                 val.append(data.pop(0))
298             return val
299
300         def readline():
301             val = bytearray()
302             while not val.endswith(b'\x0a'):
303                 val.append(data.pop(0))
304             return val
305         self.client._rbfile.readline.side_effect = readline
306         self.client._rbfile.read.side_effect = read
307
308     def assertMPDReceived(self, *lines):
309         self.client._wfile.write.assert_called_with(*lines)
310
311     def test_metaclass_commands(self):
312         """Controls client has at least commands as last synchronized in
313         TestMPDClient.commands"""
314         for cmd, ret in TestMPDClient.commands.items():
315             self.assertTrue(hasattr(self.client, cmd), msg='cmd "{}" not available!'.format(cmd))
316             if ' ' in cmd:
317                 self.assertTrue(hasattr(self.client, cmd.replace(' ', '_')))
318
319     def test_fetch_nothing(self):
320         self.MPDWillReturn('OK\n')
321         self.assertIsNone(self.client.ping())
322         self.assertMPDReceived('ping\n')
323
324     def test_fetch_list(self):
325         self.MPDWillReturn('OK\n')
326         self.assertIsInstance(self.client.list('album'), list)
327         self.assertMPDReceived('list "album"\n')
328
329     def test_fetch_item(self):
330         self.MPDWillReturn('updating_db: 42\n', 'OK\n')
331         self.assertIsNotNone(self.client.update())
332
333     def test_fetch_object(self):
334         # XXX: _read_objects() doesn't wait for the final OK
335         self.MPDWillReturn('volume: 63\n', 'OK\n')
336         status = self.client.status()
337         self.assertMPDReceived('status\n')
338         self.assertIsInstance(status, dict)
339
340         # XXX: _read_objects() doesn't wait for the final OK
341         self.MPDWillReturn('OK\n')
342         stats = self.client.stats()
343         self.assertMPDReceived('stats\n')
344         self.assertIsInstance(stats, dict)
345
346         output = ['outputid: 0\n',
347                   'outputname: default detected output\n',
348                   'plugin: sndio\n',
349                   'outputenabled: 1\n']
350         self.MPDWillReturn(*output, 'OK\n')
351         outputs = self.client.outputs()
352         self.assertMPDReceived('outputs\n')
353         self.assertIsInstance(outputs, list)
354         self.assertEqual([{'outputid': '0', 'outputname': 'default detected output', 'plugin': 'sndio', 'outputenabled': '1'}], outputs)
355
356     def test_fetch_songs(self):
357         self.MPDWillReturn('file: my-song.ogg\n', 'Pos: 0\n', 'Id: 66\n', 'OK\n')
358         playlist = self.client.playlistinfo()
359
360         self.assertMPDReceived('playlistinfo\n')
361         self.assertIsInstance(playlist, list)
362         self.assertEqual(1, len(playlist))
363         e = playlist[0]
364         self.assertIsInstance(e, dict)
365         self.assertEqual('my-song.ogg', e['file'])
366         self.assertEqual('0', e['pos'])
367         self.assertEqual('66', e['id'])
368
369     def test_send_and_fetch(self):
370         self.MPDWillReturn('volume: 50\n', 'OK\n')
371         result = self.client.send_status()
372         self.assertEqual(None, result)
373         self.assertMPDReceived('status\n')
374
375         status = self.client.fetch_status()
376         self.assertEqual(1, self.client._wfile.write.call_count)
377         self.assertEqual({'volume': '50'}, status)
378
379     def test_iterating(self):
380         self.MPDWillReturn('file: my-song.ogg\n', 'Pos: 0\n', 'Id: 66\n',
381                            'file: my-song.ogg\n', 'Pos: 0\n', 'Id: 66\n', 'OK\n')
382         self.client.iterate = True
383         playlist = self.client.playlistinfo()
384         self.assertMPDReceived('playlistinfo\n')
385         self.assertIsInstance(playlist, types.GeneratorType)
386         self.assertTrue(self.client._iterating)
387         for song in playlist:
388             self.assertRaises(musicpd.IteratingError, self.client.status)
389             self.assertIsInstance(song, dict)
390             self.assertEqual('my-song.ogg', song['file'])
391             self.assertEqual('0', song['pos'])
392             self.assertEqual('66', song['id'])
393         self.assertFalse(self.client._iterating)
394
395     def test_noidle(self):
396         self.MPDWillReturn('OK\n') # nothing changed after idle-ing
397         self.client.send_idle()
398         self.MPDWillReturn('OK\n') # nothing changed after noidle
399         self.assertEqual(self.client.noidle(), [])
400         self.assertMPDReceived('noidle\n')
401         self.MPDWillReturn('volume: 50\n', 'OK\n')
402         self.client.status()
403         self.assertMPDReceived('status\n')
404
405     def test_noidle_while_idle_started_sending(self):
406         self.MPDWillReturn('OK\n') # nothing changed after idle
407         self.client.send_idle()
408         self.MPDWillReturn('CHANGED: player\n', 'OK\n')  # noidle response
409         self.assertEqual(self.client.noidle(), ['player'])
410         self.MPDWillReturn('volume: 50\n', 'OK\n')
411         status = self.client.status()
412         self.assertMPDReceived('status\n')
413         self.assertEqual({'volume': '50'}, status)
414
415     def test_throw_when_calling_noidle_withoutidling(self):
416         self.assertRaises(musicpd.CommandError, self.client.noidle)
417         self.client.send_status()
418         self.assertRaises(musicpd.CommandError, self.client.noidle)
419
420     def test_client_to_client(self):
421         self.MPDWillReturn('OK\n')
422         self.assertIsNone(self.client.subscribe("monty"))
423         self.assertMPDReceived('subscribe "monty"\n')
424
425         self.MPDWillReturn('channel: monty\n', 'OK\n')
426         channels = self.client.channels()
427         self.assertMPDReceived('channels\n')
428         self.assertEqual(['monty'], channels)
429
430         self.MPDWillReturn('OK\n')
431         self.assertIsNone(self.client.sendmessage('monty', 'SPAM'))
432         self.assertMPDReceived('sendmessage "monty" "SPAM"\n')
433
434         self.MPDWillReturn('channel: monty\n', 'message: SPAM\n', 'OK\n')
435         msg = self.client.readmessages()
436         self.assertMPDReceived('readmessages\n')
437         self.assertEqual(msg, [{'channel': 'monty', 'message': 'SPAM'}])
438
439         self.MPDWillReturn('OK\n')
440         self.assertIsNone(self.client.unsubscribe('monty'))
441         self.assertMPDReceived('unsubscribe "monty"\n')
442
443         self.MPDWillReturn('OK\n')
444         channels = self.client.channels()
445         self.assertMPDReceived('channels\n')
446         self.assertEqual([], channels)
447
448     def test_ranges_in_command_args(self):
449         self.MPDWillReturn('OK\n')
450         self.client.playlistinfo((10,))
451         self.assertMPDReceived('playlistinfo 10:\n')
452
453         self.MPDWillReturn('OK\n')
454         self.client.playlistinfo(('10',))
455         self.assertMPDReceived('playlistinfo 10:\n')
456
457         self.MPDWillReturn('OK\n')
458         self.client.playlistinfo((10, 12))
459         self.assertMPDReceived('playlistinfo 10:12\n')
460
461         self.MPDWillReturn('OK\n')
462         self.client.rangeid(())
463         self.assertMPDReceived('rangeid :\n')
464
465         for arg in [(10, 't'), (10, 1, 1), (None,1)]:
466             self.MPDWillReturn('OK\n')
467             with self.assertRaises(musicpd.CommandError):
468                 self.client.playlistinfo(arg)
469
470     def test_numbers_as_command_args(self):
471         self.MPDWillReturn('OK\n')
472         self.client.find('file', 1)
473         self.assertMPDReceived('find "file" "1"\n')
474
475     def test_commands_without_callbacks(self):
476         self.MPDWillReturn('\n')
477         self.client.close()
478         self.assertMPDReceived('close\n')
479
480         # XXX: what are we testing here?
481         self.client._reset()
482         self.client.connect(TEST_MPD_HOST, TEST_MPD_PORT)
483
484     def test_connection_lost(self):
485         # Simulate a connection lost: the socket returns empty strings
486         self.MPDWillReturn('')
487
488         with self.assertRaises(musicpd.ConnectionError):
489             self.client.status()
490
491         # consistent behaviour
492         # solves <https://github.com/Mic92/python-mpd2/issues/11> (also present
493         # in python-mpd)
494         with self.assertRaises(musicpd.ConnectionError):
495             self.client.status()
496
497         self.assertIs(self.client._sock, None)
498
499     def test_parse_sticker_get_no_sticker(self):
500         self.MPDWillReturn('ACK [50@0] {sticker} no such sticker\n')
501         self.assertRaises(musicpd.CommandError,
502                           self.client.sticker_get, 'song', 'baz', 'foo')
503
504     def test_parse_sticker_list(self):
505         self.MPDWillReturn('sticker: foo=bar\n', 'sticker: lom=bok\n', 'OK\n')
506         res = self.client.sticker_list('song', 'baz')
507         self.assertEqual(['foo=bar', 'lom=bok'], res)
508
509         # Even with only one sticker, we get a dict
510         self.MPDWillReturn('sticker: foo=bar\n', 'OK\n')
511         res = self.client.sticker_list('song', 'baz')
512         self.assertEqual(['foo=bar'], res)
513
514     def test_albumart(self):
515         # here is a 34 bytes long data
516         data = bytes('\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01'
517                      '\x00\x01\x00\x00\xff\xdb\x00C\x00\x05\x03\x04',
518                      encoding='utf8')
519         read_lines = [b'size: 42\nbinary: 34\n', data, b'\nOK\n']
520         self.MPDWillReturnBinary(read_lines)
521         # Reading albumart / offset 0 should return the data
522         res = self.client.albumart('muse/Raised Fist/2002-Dedication/', 0)
523         self.assertEqual(res.get('data'), data)
524
525     def test_reading_binary(self):
526         # readpicture when there are no picture returns empty object
527         self.MPDWillReturnBinary([b'OK\n'])
528         res = self.client.readpicture('muse/Raised Fist/2002-Dedication/', 0)
529         self.assertEqual(res, {})
530
531     def test_command_list(self):
532         self.MPDWillReturn('updating_db: 42\n',
533                            f'{musicpd.NEXT}\n',
534                            'repeat: 0\n',
535                            'random: 0\n',
536                            f'{musicpd.NEXT}\n',
537                            f'{musicpd.NEXT}\n',
538                            'OK\n')
539         self.client.command_list_ok_begin()
540         self.client.update()
541         self.client.status()
542         self.client.repeat(1)
543         self.client.command_list_end()
544         self.assertMPDReceived('command_list_end\n')
545
546     def test_two_word_commands(self):
547         self.MPDWillReturn('OK\n')
548         self.client.tagtypes_clear()
549         self.assertMPDReceived('tagtypes clear\n')
550         self.MPDWillReturn('OK\n')
551         with self.assertRaises(AttributeError):
552             self.client.foo_bar()
553
554 class testConnection(unittest.TestCase):
555
556     def test_exposing_fileno(self):
557         with mock.patch('musicpd.socket') as socket_mock:
558             sock = mock.MagicMock(name='socket')
559             socket_mock.socket.return_value = sock
560             cli = musicpd.MPDClient()
561             cli.connect()
562             cli.fileno()
563             cli._sock.fileno.assert_called_with()
564
565     def test_connect_abstract(self):
566         os.environ['MPD_HOST'] = '@abstract'
567         with mock.patch('musicpd.socket') as socket_mock:
568             sock = mock.MagicMock(name='socket')
569             socket_mock.socket.return_value = sock
570             cli = musicpd.MPDClient()
571             cli.connect()
572             sock.connect.assert_called_with('\0abstract')
573
574     def test_connect_unix(self):
575         os.environ['MPD_HOST'] = '/run/mpd/socket'
576         with mock.patch('musicpd.socket') as socket_mock:
577             sock = mock.MagicMock(name='socket')
578             socket_mock.socket.return_value = sock
579             cli = musicpd.MPDClient()
580             cli.connect()
581             sock.connect.assert_called_with('/run/mpd/socket')
582
583
584 class testException(unittest.TestCase):
585
586     def test_CommandError_on_newline(self):
587         os.environ['MPD_HOST'] = '/run/mpd/socket'
588         with mock.patch('musicpd.socket') as socket_mock:
589             sock = mock.MagicMock(name='socket')
590             socket_mock.socket.return_value = sock
591             cli = musicpd.MPDClient()
592             cli.connect()
593             with self.assertRaises(musicpd.CommandError):
594                 cli.find('(album == "foo\nbar")')
595
596
597 if __name__ == '__main__':
598     unittest.main()