1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 17:06:17 +00:00

Rewritten and improved existing tests

This commit is contained in:
spl0k 2017-11-01 20:55:35 +01:00
parent d8c3b9fa88
commit d19886fafa
8 changed files with 735 additions and 237 deletions

View File

@ -35,6 +35,7 @@ setup(
zip_safe=False, zip_safe=False,
include_package_data=True, include_package_data=True,
test_suite="tests.suite", test_suite="tests.suite",
tests_require = [ 'lxml' ],
classifiers=[ classifiers=[
'Development Status :: 3 - Alpha', 'Development Status :: 3 - Alpha',
'Environment :: Console', 'Environment :: Console',

View File

@ -13,7 +13,6 @@ import unittest
import base, managers, api import base, managers, api
from .test_api import ApiTestCase
from .test_frontend import FrontendTestCase from .test_frontend import FrontendTestCase
def suite(): def suite():
@ -23,7 +22,6 @@ def suite():
suite.addTest(api.suite()) suite.addTest(api.suite())
suite.addTest(managers.suite()) suite.addTest(managers.suite())
suite.addTest(unittest.makeSuite(ApiTestCase))
suite.addTest(unittest.makeSuite(FrontendTestCase)) suite.addTest(unittest.makeSuite(FrontendTestCase))
return suite return suite

View File

@ -12,12 +12,16 @@ import unittest
from .test_response_helper import suite as rh_suite from .test_response_helper import suite as rh_suite
from .test_api_setup import ApiSetupTestCase from .test_api_setup import ApiSetupTestCase
from .test_system import SystemTestCase
from .test_user import UserTestCase
def suite(): def suite():
suite = unittest.TestSuite() suite = unittest.TestSuite()
suite.addTest(rh_suite()) suite.addTest(rh_suite())
suite.addTest(unittest.makeSuite(ApiSetupTestCase)) suite.addTest(unittest.makeSuite(ApiSetupTestCase))
suite.addTest(unittest.makeSuite(SystemTestCase))
suite.addTest(unittest.makeSuite(UserTestCase))
return suite return suite

104
tests/api/apitestbase.py Normal file
View File

@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
import unittest
import re
import sys
from lxml import etree
from supysonic.managers.user import UserManager
from .appmock import AppMock
path_replace_regexp = re.compile(r'/(\w+)')
NS = '{http://subsonic.org/restapi}'
class ApiTestBase(unittest.TestCase):
def setUp(self):
app_mock = AppMock()
self.app = app_mock.app
self.store = app_mock.store
self.client = self.app.test_client()
sys.modules['supysonic.web'] = app_mock
import supysonic.api
UserManager.add(self.store, 'alice', 'Alic3', 'test@example.com', True)
UserManager.add(self.store, 'bob', 'B0b', 'bob@example.com', False)
xsd = etree.parse('tests/assets/subsonic-rest-api-1.8.0.xsd')
self.schema = etree.XMLSchema(xsd)
def tearDown(self):
self.store.close()
to_unload = [ m for m in sys.modules if m.startswith('supysonic') ]
for m in to_unload:
del sys.modules[m]
def _find(self, xml, path):
"""
Helper method that insert the namespace in XPath 'path'
"""
path = path_replace_regexp.sub(r'/{}\1'.format(NS), path)
return xml.find(path)
def _make_request(self, endpoint, args = {}, tag = None, error = None, skip_post = False):
"""
Makes both a GET and POST requests against the API, assert both get the same response.
If the user isn't provided with the 'u' and 'p' in 'args', the default 'alice' is used.
Validate the response against the Subsonic API XSD and assert it matches the expected tag or error.
:param endpoint: request endpoint, with the '/rest/'prefix nor the '.view' suffix
:param args: dict of parameters. 'u', 'p', 'c' and 'v' are automatically added
:param tag: topmost expected element, right beneath 'subsonic-response'
:param error: if the given 'args' should produce an error at 'endpoint', this is the expected error code
:param skip_post: don't do the POST request
:return: a 2-tuple (resp, child) if no error, where 'resp' is the full response object, 'child' a
'lxml.etree.Element' mathching 'tag' (if any). If there's an error (when expected), only returns
the response object
"""
if not isinstance(args, dict):
raise TypeError("'args', expecting a dict, got " + type(args).__name__)
if tag and not isinstance(tag, basestring):
raise TypeError("'tag', expecting a str, got " + type(tag).__name__)
args.update({ 'c': 'tests', 'v': '1.8.0' })
if 'u' not in args:
args.update({ 'u': 'alice', 'p': 'Alic3' })
uri = '/rest/{}.view'.format(endpoint)
rg = self.client.get(uri, query_string = args)
if not skip_post:
rp = self.client.post(uri, data = args)
self.assertEqual(rg.data, rp.data)
xml = etree.fromstring(rg.data)
self.schema.assert_(xml)
if 'status="ok"' in rg.data:
self.assertIsNone(error)
if tag:
self.assertEqual(xml[0].tag, NS + tag)
return rg, xml[0]
else:
self.assertEqual(len(xml), 0)
return rg, None
else:
self.assertIsNone(tag)
self.assertEqual(xml[0].tag, NS + 'error')
self.assertEqual(xml[0].get('code'), str(error))
return rg

25
tests/api/test_system.py Normal file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017 Alban 'spl0k' Féron
# 2017 Óscar García Amor
#
# Distributed under terms of the GNU AGPLv3 license.
from .apitestbase import ApiTestBase
class SystemTestCase(ApiTestBase):
def test_ping(self):
self._make_request('ping')
def test_get_license(self):
rv, child = self._make_request('getLicense', tag = 'license')
self.assertEqual(child.get('valid'), 'true')
if __name__ == '__main__':
unittest.main()

153
tests/api/test_user.py Normal file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017 Alban 'spl0k' Féron
# 2017 Óscar García Amor
#
# Distributed under terms of the GNU AGPLv3 license.
import binascii
from .apitestbase import ApiTestBase
class UserTestCase(ApiTestBase):
def test_get_user(self):
# missing username
self._make_request('getUser', error = 10)
# non-existent user
self._make_request('getUser', { 'username': 'non existent' }, error = 70)
# self
rv, child = self._make_request('getUser', { 'username': 'alice' }, tag = 'user')
self.assertEqual(child.get('username'), 'alice')
self.assertEqual(child.get('adminRole'), 'true')
# other
rv, child = self._make_request('getUser', { 'username': 'bob' }, tag = 'user')
self.assertEqual(child.get('username'), 'bob')
self.assertEqual(child.get('adminRole'), 'false')
# self from non-admin
rv, child = self._make_request('getUser', { 'u': 'bob', 'p': 'B0b', 'username': 'bob' }, tag = 'user')
self.assertEqual(child.get('username'), 'bob')
self.assertEqual(child.get('adminRole'), 'false')
# other from non-admin
self._make_request('getUser', { 'u': 'bob', 'p': 'B0b', 'username': 'alice' }, error = 50)
def test_get_users(self):
# non-admin
self._make_request('getUsers', { 'u': 'bob', 'p': 'B0b' }, error = 50)
# admin
rv, child = self._make_request('getUsers', tag = 'users')
self.assertEqual(len(child), 2)
self.assertIsNotNone(self._find(child, "./user[@username='alice']"))
self.assertIsNotNone(self._find(child, "./user[@username='bob']"))
def test_create_user(self):
# non admin
self._make_request('createUser', { 'u': 'bob', 'p': 'B0b' }, error = 50)
# missing params, testing every combination, maybe overkill
self._make_request('createUser', error = 10)
self._make_request('createUser', { 'username': 'user' }, error = 10)
self._make_request('createUser', { 'password': 'pass' }, error = 10)
self._make_request('createUser', { 'email': 'email@example.com' }, error = 10)
self._make_request('createUser', { 'username': 'user', 'password': 'pass' }, error = 10)
self._make_request('createUser', { 'username': 'user', 'email': 'email@example.com' }, error = 10)
self._make_request('createUser', { 'password': 'pass', 'email': 'email@example.com' }, error = 10)
# duplicate
self._make_request('createUser', { 'username': 'bob', 'password': 'pass', 'email': 'me@bob.com' }, error = 0)
# test we only got our two initial users
rv, child = self._make_request('getUsers', tag = 'users')
self.assertEqual(len(child), 2)
# create users
self._make_request('createUser', { 'username': 'charlie', 'password': 'Ch4rl1e', 'email': 'unicorn@example.com', 'adminRole': True }, skip_post = True)
rv, child = self._make_request('getUser', { 'username': 'charlie' }, tag = 'user')
self.assertEqual(child.get('username'), 'charlie')
self.assertEqual(child.get('email'), 'unicorn@example.com')
self.assertEqual(child.get('adminRole'), 'true')
self._make_request('createUser', { 'username': 'dave', 'password': 'Dav3', 'email': 'dave@example.com' }, skip_post = True)
rv, child = self._make_request('getUser', { 'username': 'dave' }, tag = 'user')
self.assertEqual(child.get('username'), 'dave')
self.assertEqual(child.get('email'), 'dave@example.com')
self.assertEqual(child.get('adminRole'), 'false')
rv, child = self._make_request('getUsers', tag = 'users')
self.assertEqual(len(child), 4)
def test_delete_user(self):
# non admin
self._make_request('deleteUser', { 'u': 'bob', 'p': 'B0b', 'username': 'alice' }, error = 50)
# missing param
self._make_request('deleteUser', error = 10)
# non existing
self._make_request('deleteUser', { 'username': 'charlie' }, error = 70)
# test we still got our two initial users
rv, child = self._make_request('getUsers', tag = 'users')
self.assertEqual(len(child), 2)
# delete user
self._make_request('deleteUser', { 'username': 'bob' }, skip_post = True)
rv, child = self._make_request('getUsers', tag = 'users')
self.assertEqual(len(child), 1)
def test_change_password(self):
# missing parameter
self._make_request('changePassword', error = 10)
self._make_request('changePassword', { 'username': 'alice' }, error = 10)
self._make_request('changePassword', { 'password': 'newpass' }, error = 10)
# admin change self
self._make_request('changePassword', { 'username': 'alice', 'password': 'newpass' }, skip_post = True)
self._make_request('ping', error = 40)
self._make_request('ping', { 'u': 'alice', 'p': 'newpass' })
self._make_request('changePassword', { 'u': 'alice', 'p': 'newpass', 'username': 'alice', 'password': 'Alic3' }, skip_post = True)
# admin change other
self._make_request('changePassword', { 'username': 'bob', 'password': 'newbob' }, skip_post = True)
self._make_request('ping', { 'u': 'bob', 'p': 'B0b' }, error = 40)
self._make_request('ping', { 'u': 'bob', 'p': 'newbob' })
# non-admin change self
self._make_request('changePassword', { 'u': 'bob', 'p': 'newbob', 'username': 'bob', 'password': 'B0b' }, skip_post = True)
self._make_request('ping', { 'u': 'bob', 'p': 'newbob' }, error = 40)
self._make_request('ping', { 'u': 'bob', 'p': 'B0b' })
# non-admin change other
self._make_request('changePassword', { 'u': 'bob', 'p': 'B0b', 'username': 'alice', 'password': 'newpass' }, skip_post = True, error = 50)
self._make_request('ping', { 'u': 'alice', 'p': 'newpass' }, error = 40)
self._make_request('ping')
# change non existing
self._make_request('changePassword', { 'username': 'nonexsistent', 'password': 'pass' }, error = 70)
# non ASCII chars
self._make_request('changePassword', { 'username': 'alice', 'password': 'новыйпароль' }, skip_post = True)
self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' })
self._make_request('changePassword', { 'username': 'alice', 'password': 'Alic3', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True)
# non ASCII in hex encoded password
self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + binascii.hexlify('новыйпароль') }, skip_post = True)
self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' })
# new password starting with 'enc:' followed by non hex chars
self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:randomstring', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True)
self._make_request('ping', { 'u': 'alice', 'p': 'enc:randomstring' })
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,448 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.8.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@ -1,235 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2017 Alban 'spl0k' Féron
# 2017 Óscar García Amor
#
# Distributed under terms of the GNU AGPLv3 license.
from supysonic import db
from supysonic.managers.user import UserManager
import sys
import unittest
import uuid
# Create an empty sqlite database in memory
store = db.get_store("sqlite:")
# Read schema from file
with open('schema/sqlite.sql') as sql:
schema = sql.read()
# Create tables on memory database
for command in schema.split(';'):
store.execute(command)
# Create some users
UserManager.add(store, 'alice', 'alice', 'test@example.com', True)
UserManager.add(store, 'bob', 'bob', 'bob@example.com', False)
UserManager.add(store, 'charlie', 'charlie', 'charlie@example.com', False)
# Create a mockup of web
from flask import Flask
app = Flask(__name__)
class web():
app = app
store = store
sys.modules['supysonic.web'] = web()
# Import module and set app in test mode
import supysonic.api
class ApiTestCase(unittest.TestCase):
def setUp(self):
self.app = app.test_client()
def test_get_license(self):
# GET user request
rv = self.app.get('/rest/getLicense.view?u=alice&p=alice&c=test')
self.assertIn('status="ok"', rv.data)
self.assertIn('license valid="true"', rv.data)
# POST user request
rv = self.app.post('/rest/getLicense.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('status="ok"', rv.data)
self.assertIn('license valid="true"', rv.data)
def test_get_user(self):
# GET missing username
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test')
self.assertIn('message="Missing username"', rv.data)
# POST missing username
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('message="Missing username"', rv.data)
# GET non-admin request for other user
rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=alice')
self.assertIn('message="Admin restricted"', rv.data)
# POST non-admin request for other user
rv = self.app.post('/rest/getUser.view', data=dict(u='bob', p='bob', c='test', username='alice'))
self.assertIn('message="Admin restricted"', rv.data)
# GET non-existent user
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=null')
self.assertIn('message="Unknown user"', rv.data)
# POST non-existent user
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='null'))
self.assertIn('message="Unknown user"', rv.data)
# GET admin request
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice')
self.assertIn('adminRole="true"', rv.data)
self.assertIn('username="alice"', rv.data)
# POST admin request
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='alice'))
self.assertIn('adminRole="true"', rv.data)
self.assertIn('username="alice"', rv.data)
# GET admin request for other user
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=bob')
self.assertIn('username="bob"', rv.data)
self.assertIn('adminRole="false"', rv.data)
# POST admin request for other user
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='bob'))
self.assertIn('username="bob"', rv.data)
self.assertIn('adminRole="false"', rv.data)
# GET non-admin request
rv = self.app.get('/rest/getUser.view?u=charlie&p=charlie&c=test&username=charlie')
self.assertIn('username="charlie"', rv.data)
self.assertIn('adminRole="false"', rv.data)
# POST non-admin request
rv = self.app.post('/rest/getUser.view', data=dict(u='charlie', p='charlie', c='test', username='charlie'))
self.assertIn('username="charlie"', rv.data)
self.assertIn('adminRole="false"', rv.data)
def test_get_users(self):
# GET admin request
rv = self.app.get('/rest/getUsers.view?u=alice&p=alice&c=test')
self.assertIn('alice', rv.data)
self.assertIn('bob', rv.data)
self.assertIn('charlie', rv.data)
# POST admin request
rv = self.app.post('/rest/getUsers.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('alice', rv.data)
self.assertIn('bob', rv.data)
self.assertIn('charlie', rv.data)
# GET non-admin request
rv = self.app.get('/rest/getUsers.view?u=bob&p=bob&c=test')
self.assertIn('message="Admin restricted"', rv.data)
# POST non-admin request
rv = self.app.post('/rest/getUsers.view', data=dict(u='bob', p='bob', c='test'))
self.assertIn('message="Admin restricted"', rv.data)
def test_create_user(self):
# GET non-admin request
rv = self.app.get('/rest/createUser.view?u=bob&p=bob&c=test')
self.assertIn('message="Admin restricted"', rv.data)
# POST non-admin request
rv = self.app.post('/rest/createUser.view', data=dict(u='bob', p='bob', c='test'))
self.assertIn('message="Admin restricted"', rv.data)
# GET incomplete request
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test')
self.assertIn('message="Missing parameter"', rv.data)
# POST incomplete request
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('message="Missing parameter"', rv.data)
# GET create user and test that user is created
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True')
self.assertIn('status="ok"', rv.data)
rv = self.app.get('/rest/getUser.view?u=david&p=david&c=test&username=david')
self.assertIn('username="david"', rv.data)
self.assertIn('email="david@example.com"', rv.data)
self.assertIn('adminRole="true"', rv.data)
# POST create user and test that user is created
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True))
self.assertIn('status="ok"', rv.data)
rv = self.app.post('/rest/getUser.view', data=dict(u='elanor', p='elanor', c='test', username='elanor'))
self.assertIn('username="elanor"', rv.data)
self.assertIn('email="elanor@example.com"', rv.data)
self.assertIn('adminRole="true"', rv.data)
# GET create duplicate
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True')
self.assertIn('message="There is already a user with that username"', rv.data)
# POST create duplicate
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True))
self.assertIn('message="There is already a user with that username"', rv.data)
def test_delete_user(self):
# GET non-admin request
rv = self.app.get('/rest/deleteUser.view?u=bob&p=bob&c=test')
self.assertIn('message="Admin restricted"', rv.data)
# POST non-admin request
rv = self.app.post('/rest/deleteUser.view', data=dict(u='bob', p='bob', c='test'))
self.assertIn('message="Admin restricted"', rv.data)
# GET incomplete request
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test')
self.assertIn('message="Unknown user"', rv.data)
# POST incomplete request
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('message="Unknown user"', rv.data)
# GET delete non-existent user
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=nonexistent')
self.assertIn('message="Unknown user"', rv.data)
# POST delete non-existent user
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='nonexistent'))
self.assertIn('message="Unknown user"', rv.data)
# GET delete existent user
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=elanor')
self.assertIn('status="ok"', rv.data)
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=elanor')
self.assertIn('message="Unknown user"', rv.data)
# POST delete existent user
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='david'))
self.assertIn('status="ok"', rv.data)
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='david'))
self.assertIn('message="Unknown user"', rv.data)
def test_change_password(self):
# GET incomplete request
rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test')
self.assertIn('message="Missing parameter"', rv.data)
# POST incomplete request
rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test'))
self.assertIn('message="Missing parameter"', rv.data)
# GET non-admin change own password
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword')
self.assertIn('status="ok"', rv.data)
# POST non-admin change own password
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob'))
self.assertIn('status="ok"', rv.data)
# GET non-admin change other user password
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=alice&password=newpassword')
self.assertIn('message="Admin restricted"', rv.data)
# POST non-admin change other user password
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='bob', c='test', username='alice', password='newpassword'))
self.assertIn('message="Admin restricted"', rv.data)
# GET admin change other user password
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword')
self.assertIn('status="ok"', rv.data)
# POST admin change other user password
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob'))
self.assertIn('status="ok"', rv.data)
# GET change non-existent user password
rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test&username=nonexistent&password=nonexistent')
self.assertIn('message="No such user"', rv.data)
# POST change non-existent user password
rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test', username='nonexistent', password='nonexistent'))
self.assertIn('message="No such user"', rv.data)
# GET non-admin change own password using extended utf-8 characters
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=новыйпароль')
self.assertIn('status="ok"', rv.data)
# POST non-admin change own password using extended utf-8 characters
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='новыйпароль', c='test', username='bob', password='bob'))
self.assertIn('status="ok"', rv.data)
# GET non-admin change own password using extended utf-8 characters with old enc:
rv = self.app.get('/rest/changePassword.view?u=bob&p=enc:626f62&c=test&username=bob&password=новыйпароль')
self.assertIn('status="ok"', rv.data)
# POST non-admin change own password using extended utf-8 characters with old enc:
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:d0bdd0bed0b2d18bd0b9d0bfd0b0d180d0bed0bbd18c', c='test', username='bob', password='bob'))
self.assertIn('status="ok"', rv.data)
# GET non-admin change own password using enc: in password
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=enc:test')
self.assertIn('status="ok"', rv.data)
# POST non-admin change own password using enc: in password
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:test', c='test', username='bob', password='bob'))
self.assertIn('status="ok"', rv.data)
if __name__ == '__main__':
unittest.main()