Files
music-curator/tests/conftest.py
T

133 lines
4.5 KiB
Python
Raw Normal View History

import json
import os
import sys
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),
},
}
@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"},
}