This commit is contained in:
+34
-3
@@ -1,6 +1,6 @@
|
||||
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices,
|
||||
.../player and .../player/seek to test spotify.py against, the same way fakes.py stands in
|
||||
for real HEOS hardware."""
|
||||
.../player, .../player/pause and .../player/seek to test spotify.py against, the same way
|
||||
fakes.py stands in for real HEOS hardware."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
@@ -23,8 +23,11 @@ class FakeSpotify(threading.Thread):
|
||||
self.tokens_issued = 0
|
||||
self.transfers = [] # every PUT /v1/me/player body
|
||||
self.seeks = [] # every PUT /v1/me/player/seek's position_ms
|
||||
self.pauses = 0 # how many PUT /v1/me/player/pause it took
|
||||
self.reject_refresh = False # simulate a revoked refresh token
|
||||
self.scope = "user-read-playback-state user-modify-playback-state"
|
||||
self.player = None # GET /v1/me/player's body; None is no session (204)
|
||||
self.forbid = None # a plain-text 403 body every API call answers with
|
||||
|
||||
fake = self
|
||||
|
||||
@@ -39,6 +42,19 @@ class FakeSpotify(threading.Thread):
|
||||
if payload is not None:
|
||||
self.wfile.write(json.dumps(payload).encode())
|
||||
|
||||
def _forbidden(self):
|
||||
"""The real one refuses an account the developer app has not
|
||||
listed with a bare sentence, not the usual JSON error object."""
|
||||
if fake.forbid is None:
|
||||
return False
|
||||
body = fake.forbid.encode()
|
||||
self.send_response(403)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return True
|
||||
|
||||
def _authorized(self):
|
||||
header = self.headers.get("Authorization", "")
|
||||
return header == f"Bearer {fake.valid_token}" and fake.valid_token is not None
|
||||
@@ -51,11 +67,14 @@ class FakeSpotify(threading.Thread):
|
||||
return
|
||||
fake.tokens_issued += 1
|
||||
fake.valid_token = f"token-{fake.tokens_issued}"
|
||||
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600})
|
||||
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600,
|
||||
"scope": fake.scope})
|
||||
return
|
||||
self._send(404, {"error": {"message": "not found"}})
|
||||
|
||||
def do_PUT(self):
|
||||
if self._forbidden():
|
||||
return
|
||||
if self.path == "/v1/me/player":
|
||||
if not self._authorized():
|
||||
self._send(401, {"error": {"message": "The access token expired"}})
|
||||
@@ -65,6 +84,16 @@ class FakeSpotify(threading.Thread):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/v1/me/player/pause":
|
||||
if not self._authorized():
|
||||
self._send(401, {"error": {"message": "The access token expired"}})
|
||||
return
|
||||
fake.pauses += 1
|
||||
if fake.player is not None:
|
||||
fake.player["is_playing"] = False
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path.startswith("/v1/me/player/seek?"):
|
||||
if not self._authorized():
|
||||
self._send(401, {"error": {"message": "The access token expired"}})
|
||||
@@ -83,6 +112,8 @@ class FakeSpotify(threading.Thread):
|
||||
self._send(404, {"error": {"message": "not found"}})
|
||||
|
||||
def do_GET(self):
|
||||
if self._forbidden():
|
||||
return
|
||||
if self.path == "/v1/me/player/devices":
|
||||
if not self._authorized():
|
||||
self._send(401, {"error": {"message": "The access token expired"}})
|
||||
|
||||
+34
-2
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from spotify import SpotifyClient, SpotifyError # noqa: E402
|
||||
from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError # noqa: E402
|
||||
from tests.fake_spotify import FakeSpotify # noqa: E402
|
||||
|
||||
|
||||
@@ -33,10 +33,33 @@ class SpotifyTest(unittest.TestCase):
|
||||
self.assertEqual(self.fake.transfers, [{"device_ids": ["dev-1"], "play": True}])
|
||||
|
||||
def test_resume_raises_when_no_device_has_that_name(self):
|
||||
with self.assertRaises(SpotifyError):
|
||||
with self.assertRaises(SpotifyDeviceUnavailable):
|
||||
self.client.resume("Kitchen")
|
||||
self.assertEqual(self.fake.transfers, [])
|
||||
|
||||
def test_an_invisible_device_reports_what_it_looked_for(self):
|
||||
# A room another Spotify account is signed in to is simply absent
|
||||
# here, and app.py needs the name back to go and ask who has it.
|
||||
with self.assertRaises(SpotifyDeviceUnavailable) as caught:
|
||||
self.client.resume("Living Room")
|
||||
self.assertEqual(caught.exception.device_name, "Living Room")
|
||||
|
||||
def test_it_knows_what_its_login_is_allowed_to_do(self):
|
||||
# Signing a speaker in needs the "streaming" scope, and finding that
|
||||
# out afterwards costs the room -- see app.py's _spotify_take_over.
|
||||
self.fake.scope = "user-read-playback-state streaming"
|
||||
self.assertTrue(self.client.has_scope("streaming"))
|
||||
self.assertFalse(self.client.has_scope("user-modify-playback-state"))
|
||||
|
||||
def test_pause_stops_what_the_account_is_playing(self):
|
||||
# What handing a room back starts with: the account keeps its place
|
||||
# in the queue, so its own app -- or this panel's button -- can pick
|
||||
# the stream up again somewhere else.
|
||||
self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True}
|
||||
self.client.pause()
|
||||
self.assertEqual(self.fake.pauses, 1)
|
||||
self.assertFalse(self.client.playback()["is_playing"])
|
||||
|
||||
def test_seek_asks_for_the_position(self):
|
||||
self.client.seek(90000)
|
||||
self.assertEqual(self.fake.seeks, [90000])
|
||||
@@ -60,6 +83,15 @@ class SpotifyTest(unittest.TestCase):
|
||||
self.assertEqual(names, ["Lego Room", "Home Cinema"])
|
||||
self.assertEqual(self.fake.tokens_issued, 2)
|
||||
|
||||
def test_a_plain_text_refusal_is_quoted_rather_than_flattened(self):
|
||||
# The 403 for an account missing from the developer app's User
|
||||
# Management is Spotify's only hint that that is what is wrong,
|
||||
# and it arrives as prose rather than as a JSON error object.
|
||||
self.fake.forbid = "The user is not registered for this application."
|
||||
with self.assertRaises(SpotifyError) as caught:
|
||||
self.client.devices()
|
||||
self.assertIn("not registered for this application", str(caught.exception))
|
||||
|
||||
def test_a_revoked_refresh_token_raises_a_clear_error(self):
|
||||
self.fake.reject_refresh = True
|
||||
with self.assertRaises(SpotifyError):
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The LAN half of Spotify Connect, against a stand-in for one speaker's
|
||||
/zc endpoint. What the real ones do with these calls was measured rather
|
||||
than assumed -- spotify_zc's module docstring says what and why.
|
||||
|
||||
python3 -m unittest discover -s tests -t .
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from spotify_zc import KEY_BYTES, Speaker, ZeroconfError # noqa: E402
|
||||
|
||||
|
||||
class FakeSpeaker(threading.Thread):
|
||||
"""A speaker's /zc endpoint, as much of one as this needs -- including
|
||||
its habit of answering 101 to a sign-in it has not tried yet."""
|
||||
|
||||
def __init__(self, token_type="accesstoken"):
|
||||
super().__init__(daemon=True)
|
||||
self.token_type = token_type
|
||||
self.refuse = False
|
||||
self.logins = []
|
||||
self.resets = 0
|
||||
fake = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def _send(self, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if parse_qs(urlparse(self.path).query).get("action") == ["getInfo"]:
|
||||
self._send(fake.getinfo())
|
||||
else:
|
||||
self._send({"status": 301, "statusString": "ERROR-UNKNOWN"})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()}
|
||||
if form.get("action") == "addUser":
|
||||
fake.logins.append(form)
|
||||
if fake.refuse:
|
||||
self._send({"status": 203, "statusString": "ERROR-INVALID-ARGUMENTS"})
|
||||
else:
|
||||
self._send({"status": 101, "statusString": "OK", "spotifyError": 0})
|
||||
elif form.get("action") == "resetUsers":
|
||||
fake.resets += 1
|
||||
self._send({"status": 101, "statusString": "OK"})
|
||||
else:
|
||||
self._send({"status": 301, "statusString": "ERROR-UNKNOWN"})
|
||||
|
||||
self.server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.port = self.server.server_port
|
||||
self.start()
|
||||
|
||||
def getinfo(self):
|
||||
return {
|
||||
"status": 101, "statusString": "OK", "remoteName": "Living Room",
|
||||
"deviceID": "8f190c21", "version": "2.10.0", "tokenType": self.token_type,
|
||||
}
|
||||
|
||||
def run(self):
|
||||
self.server.serve_forever(poll_interval=0.05)
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
class SpeakerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeSpeaker()
|
||||
self.addCleanup(self.fake.stop)
|
||||
self.speaker = Speaker("127.0.0.1", self.fake.port, "/zc")
|
||||
|
||||
def test_info_reads_the_speaker_back(self):
|
||||
self.assertEqual(self.speaker.info()["remoteName"], "Living Room")
|
||||
|
||||
def test_add_user_hands_over_the_token(self):
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
(login,) = self.fake.logins
|
||||
self.assertEqual(login["userName"], "fifou")
|
||||
self.assertEqual(login["tokenType"], "accesstoken")
|
||||
# Plain, because that is the only shape these speakers act on: a
|
||||
# sealed blob is taken and then never signed in with.
|
||||
self.assertEqual(login["blob"], "BQD-token")
|
||||
|
||||
def test_add_user_sends_the_client_key_the_device_demands(self):
|
||||
# Required -- a POST without one is a 400 -- and then never read,
|
||||
# so all that matters is that something of the size is there.
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
self.assertEqual(len(base64.b64decode(self.fake.logins[0]["clientKey"])), KEY_BYTES)
|
||||
|
||||
def test_every_sign_in_brings_its_own_client_key(self):
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
first, second = (login["clientKey"] for login in self.fake.logins)
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
def test_a_refused_sign_in_is_reported(self):
|
||||
self.fake.refuse = True
|
||||
with self.assertRaises(ZeroconfError) as caught:
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
self.assertIn("refused the login", str(caught.exception))
|
||||
|
||||
def test_reset_users_signs_it_out(self):
|
||||
self.speaker.reset_users()
|
||||
self.assertEqual(self.fake.resets, 1)
|
||||
|
||||
def test_an_older_speaker_says_so_rather_than_failing_obscurely(self):
|
||||
# A device on the original blob protocol wants credentials a Web API
|
||||
# token cannot produce, so there is no point sending it one.
|
||||
self.fake.token_type = "authBlob"
|
||||
with self.assertRaises(ZeroconfError) as caught:
|
||||
self.speaker.add_user("fifou", "BQD-token")
|
||||
self.assertIn("older blob protocol", str(caught.exception))
|
||||
self.assertEqual(self.fake.logins, [])
|
||||
|
||||
def test_a_speaker_that_is_not_there_is_an_error_not_a_hang(self):
|
||||
self.fake.stop()
|
||||
with self.assertRaises(ZeroconfError):
|
||||
self.speaker.info()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user