Files
musicseerr/backend/tests/services/test_lastfm_repository.py
T
2026-04-03 15:53:00 +01:00

742 lines
28 KiB
Python

import hashlib
import pytest
from unittest.mock import AsyncMock, MagicMock
import httpx
from core.exceptions import ConfigurationError, ExternalServiceError
from repositories.lastfm_repository import LastFmRepository, LASTFM_ERROR_MAP
def _make_cache() -> AsyncMock:
cache = AsyncMock()
cache.get = AsyncMock(return_value=None)
cache.set = AsyncMock()
return cache
def _make_repo(
api_key: str = "key",
shared_secret: str = "secret",
session_key: str = "",
cache: AsyncMock | None = None,
) -> LastFmRepository:
http_client = AsyncMock(spec=httpx.AsyncClient)
return LastFmRepository(
http_client=http_client,
cache=cache or _make_cache(),
api_key=api_key,
shared_secret=shared_secret,
session_key=session_key,
)
class TestBuildApiSig:
def test_basic_signature(self):
repo = _make_repo(shared_secret="mysecret")
params = {"method": "auth.getToken", "api_key": "mykey"}
sig = repo._build_api_sig(params)
expected_str = "api_keymykeymethodauth.getTokenmysecret"
expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest()
assert sig == expected
def test_excludes_format_and_callback(self):
repo = _make_repo(shared_secret="sec")
params = {
"method": "test",
"api_key": "k",
"format": "json",
"callback": "cb",
}
sig = repo._build_api_sig(params)
expected_str = "api_keykmethodtestsec"
expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest()
assert sig == expected
def test_params_sorted_alphabetically(self):
repo = _make_repo(shared_secret="s")
params = {"z_param": "z", "a_param": "a"}
sig = repo._build_api_sig(params)
expected_str = "a_paramaz_paramzs"
expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest()
assert sig == expected
class TestHandleErrorResponse:
def test_no_error_returns_none(self):
repo = _make_repo()
repo._handle_error_response({"artist": "test"})
def test_invalid_api_key_raises_configuration_error(self):
repo = _make_repo()
with pytest.raises(ConfigurationError, match="(?i)invalid api key"):
repo._handle_error_response({"error": 10, "message": "bad key"})
def test_session_expired_raises_configuration_error(self):
repo = _make_repo()
with pytest.raises(ConfigurationError, match="Session key expired"):
repo._handle_error_response({"error": 9, "message": "expired"})
def test_service_offline_raises_external_error(self):
repo = _make_repo()
with pytest.raises(ExternalServiceError, match="temporarily offline"):
repo._handle_error_response({"error": 11, "message": "offline"})
def test_unknown_error_code_raises_external_error(self):
repo = _make_repo()
with pytest.raises(ExternalServiceError, match="Last.fm error \\(999\\)"):
repo._handle_error_response({"error": 999, "message": "weird"})
class TestConfigureMethod:
def test_configure_updates_credentials(self):
repo = _make_repo(api_key="old", shared_secret="old")
repo.configure(api_key="new-key", shared_secret="new-secret", session_key="sk-1")
assert repo._api_key == "new-key"
assert repo._shared_secret == "new-secret"
assert repo._session_key == "sk-1"
class TestConstructorDefaults:
def test_default_empty_strings(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
repo = LastFmRepository(http_client=http_client, cache=_make_cache())
assert repo._api_key == ""
assert repo._shared_secret == ""
assert repo._session_key == ""
class TestUpdateNowPlaying:
@pytest.mark.asyncio
async def test_posts_with_required_params(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
result = await repo.update_now_playing(artist="Artist", track="Track")
assert result is True
call_args = http_client.post.call_args
posted_data = call_args.kwargs.get("data", call_args.args[1] if len(call_args.args) > 1 else {})
assert posted_data["method"] == "track.updateNowPlaying"
assert posted_data["artist"] == "Artist"
assert posted_data["track"] == "Track"
assert "api_sig" in posted_data
@pytest.mark.asyncio
async def test_includes_optional_params(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
await repo.update_now_playing(
artist="A", track="T", album="Album", duration=300, mbid="mb-123"
)
posted_data = http_client.post.call_args.kwargs.get(
"data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {}
)
assert posted_data["album"] == "Album"
assert posted_data["duration"] == "300"
assert posted_data["mbid"] == "mb-123"
@pytest.mark.asyncio
async def test_omits_empty_optional_params(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
await repo.update_now_playing(artist="A", track="T")
posted_data = http_client.post.call_args.kwargs.get(
"data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {}
)
assert "album" not in posted_data
assert "duration" not in posted_data
assert "mbid" not in posted_data
@pytest.mark.asyncio
async def test_signature_includes_session_key(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
await repo.update_now_playing(artist="A", track="T")
posted_data = http_client.post.call_args.kwargs.get(
"data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {}
)
assert posted_data["sk"] == "sk-1"
class TestScrobble:
@pytest.mark.asyncio
async def test_posts_with_timestamp(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"scrobbles": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
result = await repo.scrobble(artist="Artist", track="Track", timestamp=1700000000)
assert result is True
posted_data = http_client.post.call_args.kwargs.get(
"data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {}
)
assert posted_data["method"] == "track.scrobble"
assert posted_data["artist"] == "Artist"
assert posted_data["track"] == "Track"
assert posted_data["timestamp"] == "1700000000"
assert "api_sig" in posted_data
@pytest.mark.asyncio
async def test_includes_album_and_duration(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(status_code=200, json=lambda: {"scrobbles": {}}, text="")
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
await repo.scrobble(
artist="A", track="T", timestamp=1700000000, album="Alb", duration=200
)
posted_data = http_client.post.call_args.kwargs.get(
"data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {}
)
assert posted_data["album"] == "Alb"
assert posted_data["duration"] == "200"
@pytest.mark.asyncio
async def test_raises_on_api_error(self):
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.post = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {"error": 9, "message": "session expired"},
text="",
)
)
repo = LastFmRepository(
http_client=http_client,
cache=_make_cache(),
api_key="key",
shared_secret="secret",
session_key="sk-1",
)
with pytest.raises(ConfigurationError, match="Session key expired"):
await repo.scrobble(artist="A", track="T", timestamp=1700000000)
class TestGetUserTopArtists:
@pytest.mark.asyncio
async def test_parses_response_and_caches(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"topartists": {
"artist": [
{"name": "Radiohead", "mbid": "a74b1b7f", "playcount": "100"},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_top_artists("user1", period="7day", limit=5)
assert len(result) == 1
assert result[0].name == "Radiohead"
assert result[0].playcount == 100
cache.set.assert_called_once()
@pytest.mark.asyncio
async def test_returns_cached_data(self):
from repositories.lastfm_models import LastFmArtist
cached_data = [LastFmArtist(name="Cached", playcount=50)]
cache = _make_cache()
cache.get = AsyncMock(return_value=cached_data)
repo = _make_repo(cache=cache)
result = await repo.get_user_top_artists("user1")
assert result[0].name == "Cached"
assert repo._client.get.call_count == 0
@pytest.mark.asyncio
async def test_invalid_period_defaults_to_overall(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {"topartists": {"artist": []}},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
await repo.get_user_top_artists("user1", period="invalid")
call_params = http_client.get.call_args.kwargs.get("params", {})
assert call_params.get("period") == "overall"
class TestGetSimilarArtists:
@pytest.mark.asyncio
async def test_parses_by_name(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"similarartists": {
"artist": [
{"name": "Muse", "mbid": "abc", "match": "0.9"},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_similar_artists("Radiohead", limit=5)
assert len(result) == 1
assert result[0].name == "Muse"
assert result[0].match == pytest.approx(0.9)
@pytest.mark.asyncio
async def test_uses_mbid_when_provided(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {"similarartists": {"artist": []}},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
await repo.get_similar_artists("Radiohead", mbid="abc-123", limit=5)
call_params = http_client.get.call_args.kwargs.get("params", {})
assert call_params.get("mbid") == "abc-123"
assert "artist" not in call_params
class TestGetUserWeeklyAlbumChart:
@pytest.mark.asyncio
async def test_parses_weekly_album_chart(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"weeklyalbumchart": {
"album": [
{
"name": "OK Computer",
"artist": {"#text": "Radiohead"},
"mbid": "rg-1",
"playcount": "12",
},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_weekly_album_chart("user1")
assert len(result) == 1
assert result[0].name == "OK Computer"
assert result[0].artist_name == "Radiohead"
assert result[0].playcount == 12
class TestGetUserTopAlbums:
@pytest.mark.asyncio
async def test_parses_albums(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"topalbums": {
"album": [
{
"name": "OK Computer",
"artist": {"name": "Radiohead"},
"mbid": "a1",
"playcount": "55",
"listeners": "200",
},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_top_albums("user1", period="3month", limit=5)
assert len(result) == 1
assert result[0].name == "OK Computer"
assert result[0].artist_name == "Radiohead"
assert result[0].playcount == 55
cache.set.assert_called_once()
class TestGetUserTopTracks:
@pytest.mark.asyncio
async def test_parses_tracks(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"toptracks": {
"track": [
{
"name": "Karma Police",
"artist": {"name": "Radiohead"},
"mbid": "t1",
"playcount": "30",
},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_top_tracks("user1", period="1month", limit=10)
assert len(result) == 1
assert result[0].name == "Karma Police"
assert result[0].artist_name == "Radiohead"
cache.set.assert_called_once()
class TestGetUserRecentTracks:
@pytest.mark.asyncio
async def test_parses_recent_tracks(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"recenttracks": {
"track": [
{
"name": "Everything In Its Right Place",
"artist": {"#text": "Radiohead", "mbid": "a1"},
"album": {"#text": "Kid A", "mbid": "al1"},
"date": {"uts": "1700000000"},
},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_recent_tracks("user1", limit=10)
assert len(result) == 1
assert result[0].track_name == "Everything In Its Right Place"
assert result[0].artist_name == "Radiohead"
assert result[0].album_name == "Kid A"
assert result[0].timestamp == 1700000000
@pytest.mark.asyncio
async def test_now_playing_flag(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"recenttracks": {
"track": [
{
"name": "NOW",
"artist": {"#text": "Band"},
"album": {"#text": "Album"},
"@attr": {"nowplaying": "true"},
},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_recent_tracks("user1")
assert result[0].now_playing is True
class TestGetUserWeeklyArtistChart:
@pytest.mark.asyncio
async def test_parses_weekly_artist_chart(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"weeklyartistchart": {
"artist": [
{"name": "Radiohead", "playcount": "25", "mbid": "a1"},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_user_weekly_artist_chart("user1")
assert len(result) == 1
assert result[0].name == "Radiohead"
assert result[0].playcount == 25
class TestGetGlobalTopTracks:
@pytest.mark.asyncio
async def test_parses_global_tracks(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"toptracks": {
"track": [
{"name": "Blinding Lights", "artist": {"name": "The Weeknd"}, "playcount": "9999"},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_global_top_tracks(limit=10)
assert len(result) == 1
assert result[0].name == "Blinding Lights"
assert result[0].artist_name == "The Weeknd"
class TestGetTagTopArtists:
@pytest.mark.asyncio
async def test_parses_tag_artists(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"topartists": {
"artist": [
{"name": "Pink Floyd", "mbid": "pf1", "playcount": "500"},
]
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_tag_top_artists("progressive rock", limit=10)
assert len(result) == 1
assert result[0].name == "Pink Floyd"
class TestGetAlbumInfo:
@pytest.mark.asyncio
async def test_parses_album_info_with_tracks(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"album": {
"name": "OK Computer",
"artist": "Radiohead",
"mbid": "al1",
"listeners": "3000",
"playcount": "50000",
"url": "https://last.fm/album",
"wiki": {"summary": "A classic album."},
"tags": {"tag": [{"name": "rock", "url": "https://last.fm/tag/rock"}]},
"tracks": {
"track": [
{"name": "Airbag", "duration": "283", "@attr": {"rank": "1"}, "url": "https://last.fm/track"},
{"name": "Paranoid Android", "duration": "390", "@attr": {"rank": "2"}, "url": ""},
]
},
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_album_info("Radiohead", "OK Computer")
assert result is not None
assert result.name == "OK Computer"
assert result.artist_name == "Radiohead"
assert result.summary == "A classic album."
assert result.listeners == 3000
assert len(result.tags) == 1
assert result.tags[0].name == "rock"
assert len(result.tracks) == 2
assert result.tracks[0].name == "Airbag"
assert result.tracks[0].duration == 283
assert result.tracks[0].rank == 1
assert result.tracks[1].name == "Paranoid Android"
@pytest.mark.asyncio
async def test_not_found_returns_none(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {"error": 6, "message": "Album not found"},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_album_info("X", "Y")
assert result is None
class TestGetArtistInfoNotFound:
@pytest.mark.asyncio
async def test_not_found_returns_none(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {"error": 6, "message": "Artist not found"},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_artist_info("Nonexistent Artist")
assert result is None
class TestGetArtistInfoParsing:
@pytest.mark.asyncio
async def test_parses_full_artist_info(self):
cache = _make_cache()
http_client = AsyncMock(spec=httpx.AsyncClient)
http_client.get = AsyncMock(
return_value=MagicMock(
status_code=200,
json=lambda: {
"artist": {
"name": "Radiohead",
"mbid": "a1",
"stats": {"listeners": "5000", "playcount": "80000"},
"url": "https://last.fm/artist",
"bio": {"summary": "English rock band."},
"tags": {"tag": [{"name": "alternative", "url": "https://last.fm/tag/alt"}]},
"similar": {
"artist": [
{"name": "Muse", "mbid": "m1", "match": "0.85"},
]
},
}
},
text="",
)
)
repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k")
result = await repo.get_artist_info("Radiohead")
assert result is not None
assert result.name == "Radiohead"
assert result.listeners == 5000
assert result.playcount == 80000
assert result.bio_summary == "English rock band."
assert len(result.tags) == 1
assert result.tags[0].name == "alternative"
assert len(result.similar) == 1
assert result.similar[0].name == "Muse"
assert result.similar[0].match == pytest.approx(0.85)
cache.set.assert_called_once()
class TestCacheBehavior:
@pytest.mark.asyncio
async def test_returns_cached_global_top_artists(self):
from repositories.lastfm_models import LastFmArtist
cached_data = [LastFmArtist(name="Cached", playcount=10)]
cache = _make_cache()
cache.get = AsyncMock(return_value=cached_data)
repo = _make_repo(cache=cache)
result = await repo.get_global_top_artists(limit=5)
assert result[0].name == "Cached"
assert repo._client.get.call_count == 0
@pytest.mark.asyncio
async def test_returns_cached_tag_top_artists(self):
from repositories.lastfm_models import LastFmArtist
cached_data = [LastFmArtist(name="TagCached", playcount=20)]
cache = _make_cache()
cache.get = AsyncMock(return_value=cached_data)
repo = _make_repo(cache=cache)
result = await repo.get_tag_top_artists("rock", limit=5)
assert result[0].name == "TagCached"
assert repo._client.get.call_count == 0
@pytest.mark.asyncio
async def test_returns_cached_album_info(self):
from repositories.lastfm_models import LastFmAlbumInfo
cached_data = LastFmAlbumInfo(name="Cached Album", artist_name="A")
cache = _make_cache()
cache.get = AsyncMock(return_value=cached_data)
repo = _make_repo(cache=cache)
result = await repo.get_album_info("A", "Cached Album")
assert result.name == "Cached Album"
assert repo._client.get.call_count == 0