Files
music-curator/tests/conftest.py
T

296 lines
11 KiB
Python
Raw Normal View History

import http.server
import io
import json
import os
import sys
import threading
import urllib.error
import urllib.parse
import pytest
# Ensure the project root is on sys.path when running tests.
ROOT = os.path.dirname(os.path.dirname(__file__))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def make_tracks(count, start=1_600_000_000, step=300, artists=5):
"""Return a synthetic recent-tracks history, oldest first.
Mirrors the real payload's quirks: artist and album are sub-objects keyed
`#text`, MBIDs are present-but-empty rather than absent when unknown, and
only some entries carry one.
"""
return [
{
"artist": {"#text": f"Artist {index % artists}", "mbid": f"artist-{index % artists}"},
"album": {"#text": f"Album {index % 7}", "mbid": ""},
"name": f"Track {index}",
"mbid": f"recording-{index}" if index % 3 == 0 else "",
"date": {"uts": str(start + index * step), "#text": "whenever"},
}
for index in range(count)
]
def make_loved(names):
"""Return loved-track entries, which key the artist as `name` not `#text`."""
return [
{
"name": name,
"mbid": "",
"artist": {"name": "Artist 0", "mbid": "artist-0"},
"date": {"uts": "1600000000"},
}
for name in names
]
class FakeLastfm:
"""A transport serving a canned history, so the tests need no network.
Honours `from`, `to`, `limit` and `page` the way the real service does, and
reproduces the two shapes that catch clients out: a lone result comes back
as a bare object rather than a one-item list, and a currently-playing track
is prepended to the first page with no `date`.
"""
def __init__(self, tracks=(), loved=(), nowplaying=None, outcomes=()):
self.tracks = sorted(tracks, key=lambda track: int(track["date"]["uts"]), reverse=True)
self.loved = list(loved)
self.nowplaying = nowplaying
# Served in order before any real response; an Exception is raised.
self.outcomes = list(outcomes)
self.calls = []
def __call__(self, url, timeout=None):
query = {
key: value[0]
for key, value in urllib.parse.parse_qs(urllib.parse.urlparse(url).query).items()
}
self.calls.append(query)
if self.outcomes:
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return json.dumps(outcome)
method = query["method"].lower()
if method == "user.getrecenttracks":
return json.dumps(self._recent(query))
if method == "user.getlovedtracks":
return json.dumps(self._loved(query))
raise AssertionError(f"unexpected method {method}")
def _recent(self, query):
selected = self.tracks
if "to" in query:
selected = [t for t in selected if int(t["date"]["uts"]) <= int(query["to"])]
if "from" in query:
selected = [t for t in selected if int(t["date"]["uts"]) >= int(query["from"])]
window = self._page(selected, query)
if int(query.get("page", 1)) == 1 and self.nowplaying is not None:
window = [self.nowplaying, *window]
return {"recenttracks": self._wrap(window, selected, query)}
def _loved(self, query):
window = self._page(self.loved, query)
return {"lovedtracks": self._wrap(window, self.loved, query)}
@staticmethod
def _page(items, query):
limit = int(query.get("limit", 50))
page = int(query.get("page", 1))
start = (page - 1) * limit
return items[start : start + limit]
@staticmethod
def _wrap(window, selected, query):
limit = int(query.get("limit", 50))
total = len(selected)
return {
# A single result is not wrapped in a list by the real service.
"track": window[0] if len(window) == 1 else window,
"@attr": {
"user": query.get("user", ""),
"page": query.get("page", "1"),
"perPage": str(limit),
"totalPages": str(max(1, -(-total // limit))),
"total": str(total),
},
}
class FakeLidarr:
"""A transport serving a canned library over the Lidarr v1 API surface.
Built from a nested description -- artist, album, tracks -- and split back
out across the four endpoints the indexer actually calls, so the tests
exercise the same stitching the real thing does.
"""
def __init__(self, artists=(), fail=()):
# (path, artistId) pairs the fake refuses to serve, standing in for the
# 500s Lidarr returns on data it cannot hydrate.
self.fail = set(fail)
self.artists, self.albums, self.tracks, self.files = [], [], [], []
for artist_index, entry in enumerate(artists, start=1):
artist_id = artist_index
self.artists.append(
{
"id": artist_id,
"artistName": entry["name"],
"foreignArtistId": entry.get("mbid", f"artist-mbid-{artist_id}"),
"path": f"/music/{entry['name']}",
"monitored": entry.get("monitored", True),
}
)
for album_index, album in enumerate(entry.get("albums", []), start=1):
album_id = artist_id * 100 + album_index
self.albums.append(
{
"id": album_id,
"artistId": artist_id,
"title": album["title"],
"foreignAlbumId": f"album-mbid-{album_id}",
"monitored": album.get("monitored", True),
"releaseDate": album.get("release_date", "2019-01-01T00:00:00Z"),
}
)
for track_index, track in enumerate(album.get("tracks", []), start=1):
track_id = album_id * 100 + track_index
has_file = track.get("has_file", True)
self.tracks.append(
{
"id": track_id,
"artistId": artist_id,
"albumId": album_id,
"title": track["title"],
"foreignRecordingId": track.get("recording_mbid", ""),
"trackFileId": track_id if has_file else 0,
"hasFile": has_file,
"duration": 210000,
}
)
if has_file:
self.files.append(
{
"id": track_id,
"artistId": artist_id,
"albumId": album_id,
"path": f"/music/{entry['name']}/{album['title']}/"
f"{track['title']}.flac",
"dateAdded": track.get("added", "2020-05-01T12:00:00Z"),
}
)
self.calls = []
def __call__(self, url, timeout=None, headers=None):
parsed = urllib.parse.urlparse(url)
assert (headers or {}).get("X-Api-Key"), "Lidarr requires the API key header"
path = parsed.path.rsplit("/", 1)[-1]
query = {
key: value[0] for key, value in urllib.parse.parse_qs(parsed.query).items()
}
self.calls.append((path, query))
artist_id = int(query.get("artistId", 0))
if (path, artist_id) in self.fail:
raise urllib.error.HTTPError(
url, 500, "Internal Server Error", {}, io.BytesIO(b'{"message": "boom"}')
)
album_ids = query.get("albumIds")
if path == "album" and album_ids:
album_id = int(album_ids)
if ("albumid", album_id) in self.fail:
raise urllib.error.HTTPError(
url,
500,
"Internal Server Error",
{},
io.BytesIO(b'{"message": "Sequence contains more than one element"}'),
)
return json.dumps([row for row in self.albums if row["id"] == album_id])
if path == "artist":
return json.dumps(self.artists)
source = {"album": self.albums, "track": self.tracks, "trackfile": self.files}[path]
if path == "album" and not artist_id:
# Lidarr's unfiltered album endpoint returns the lot.
return json.dumps(source)
if not artist_id:
raise urllib.error.HTTPError(
url,
400,
"Bad Request",
{},
io.BytesIO(b'{"message": "artistId must be provided"}'),
)
return json.dumps([row for row in source if row["artistId"] == artist_id])
@pytest.fixture
def now_playing():
"""The entry Last.fm prepends for a track in progress: no `date` at all."""
return {
"artist": {"#text": "Artist 0", "mbid": "artist-0"},
"album": {"#text": "Album 0", "mbid": ""},
"name": "Currently Playing",
"mbid": "",
"@attr": {"nowplaying": "true"},
}
class _CountingServer(http.server.ThreadingHTTPServer):
"""Counts accepted connections, which is what connection reuse is about."""
daemon_threads = True
def __init__(self, *args, **kwargs):
self.connections = 0
super().__init__(*args, **kwargs)
def process_request(self, request, client_address):
self.connections += 1
super().process_request(request, client_address)
class _Handler(http.server.BaseHTTPRequestHandler):
# Without HTTP/1.1 the server closes after every response and no client
# could reuse anything, which would make the test prove nothing.
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path.startswith("/boom"):
body, status = b'{"message": "boom"}', 500
else:
body = json.dumps(
{"path": self.path, "key": self.headers.get("X-Api-Key")}
).encode()
status = 200
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def http_server():
"""A real local HTTP server, for the one component that talks sockets."""
server = _CountingServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()