"""Tests for the MusicBrainz API helper module.""" import importlib.util import json import sys import time from io import BytesIO from unittest.mock import MagicMock, patch # -- Load module directly ---------------------------------------------------- _spec = importlib.util.spec_from_file_location( "_musicbrainz", "plugins/_musicbrainz.py", ) _mod = importlib.util.module_from_spec(_spec) sys.modules["_musicbrainz"] = _mod _spec.loader.exec_module(_mod) # -- Helpers ----------------------------------------------------------------- def _make_resp(data: dict) -> MagicMock: """Create a fake HTTP response with JSON body.""" resp = MagicMock() resp.read.return_value = json.dumps(data).encode() return resp # --------------------------------------------------------------------------- # TestMbRequest # --------------------------------------------------------------------------- class TestMbRequest: def setup_method(self): _mod._last_request = 0.0 def test_returns_parsed_json(self): resp = _make_resp({"status": "ok"}) with patch("derp.http.urlopen", return_value=resp): result = _mod._mb_request("artist", {"query": "Tool"}) assert result == {"status": "ok"} def test_rate_delay_enforced(self): """Second call within rate interval triggers sleep.""" _mod._last_request = time.monotonic() resp = _make_resp({}) slept = [] with patch("derp.http.urlopen", return_value=resp), \ patch.object(_mod.time, "sleep", side_effect=slept.append), \ patch.object(_mod.time, "monotonic", return_value=_mod._last_request + 0.2): _mod._mb_request("artist", {"query": "X"}) assert len(slept) == 1 assert slept[0] > 0 def test_no_delay_when_interval_elapsed(self): """No sleep when enough time has passed since last request.""" _mod._last_request = time.monotonic() - 5.0 resp = _make_resp({}) with patch("derp.http.urlopen", return_value=resp), \ patch.object(_mod.time, "sleep") as mock_sleep: _mod._mb_request("artist", {"query": "X"}) mock_sleep.assert_not_called() def test_returns_empty_on_error(self): with patch("derp.http.urlopen", side_effect=ConnectionError("fail")): result = _mod._mb_request("artist", {"query": "X"}) assert result == {} def test_updates_last_request_on_success(self): _mod._last_request = 0.0 resp = _make_resp({}) with patch("derp.http.urlopen", return_value=resp): _mod._mb_request("test") assert _mod._last_request > 0 def test_updates_last_request_on_error(self): _mod._last_request = 0.0 with patch("derp.http.urlopen", side_effect=Exception("boom")): _mod._mb_request("test") assert _mod._last_request > 0 def test_none_params(self): """Handles None params without error.""" resp = _make_resp({"ok": True}) with patch("derp.http.urlopen", return_value=resp): result = _mod._mb_request("test", None) assert result == {"ok": True} # --------------------------------------------------------------------------- # TestMbSearchArtist # --------------------------------------------------------------------------- class TestMbSearchArtist: def setup_method(self): _mod._last_request = 0.0 def test_returns_mbid(self): data = {"artists": [{"id": "abc-123", "name": "Tool", "score": 100}]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_search_artist("Tool") assert result == "abc-123" def test_returns_none_no_results(self): with patch.object(_mod, "_mb_request", return_value={"artists": []}): assert _mod.mb_search_artist("Unknown") is None def test_returns_none_on_empty_response(self): with patch.object(_mod, "_mb_request", return_value={}): assert _mod.mb_search_artist("X") is None def test_returns_none_low_score(self): """Rejects matches with score below 50.""" data = {"artists": [{"id": "low", "name": "Mismatch", "score": 30}]} with patch.object(_mod, "_mb_request", return_value=data): assert _mod.mb_search_artist("Tool") is None def test_returns_none_on_error(self): with patch.object(_mod, "_mb_request", return_value={}): assert _mod.mb_search_artist("Error") is None def test_accepts_high_score(self): data = {"artists": [{"id": "abc", "name": "Tool", "score": 85}]} with patch.object(_mod, "_mb_request", return_value=data): assert _mod.mb_search_artist("Tool") == "abc" # --------------------------------------------------------------------------- # TestMbArtistTags # --------------------------------------------------------------------------- class TestMbArtistTags: def setup_method(self): _mod._last_request = 0.0 def test_returns_sorted_top_5(self): data = {"tags": [ {"name": "rock", "count": 50}, {"name": "metal", "count": 100}, {"name": "prog", "count": 80}, {"name": "alternative", "count": 60}, {"name": "hard rock", "count": 40}, {"name": "grunge", "count": 30}, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_artist_tags("mbid-123") assert len(result) == 5 assert result[0] == "metal" assert result[1] == "prog" assert result[2] == "alternative" assert result[3] == "rock" assert result[4] == "hard rock" def test_empty_tags(self): with patch.object(_mod, "_mb_request", return_value={"tags": []}): assert _mod.mb_artist_tags("mbid") == [] def test_no_tags_key(self): with patch.object(_mod, "_mb_request", return_value={}): assert _mod.mb_artist_tags("mbid") == [] def test_skips_nameless_tags(self): data = {"tags": [ {"name": "rock", "count": 50}, {"count": 100}, # no name {"name": "", "count": 80}, # empty name ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_artist_tags("mbid") assert result == ["rock"] def test_fewer_than_5_tags(self): data = {"tags": [{"name": "jazz", "count": 10}]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_artist_tags("mbid") assert result == ["jazz"] # --------------------------------------------------------------------------- # TestMbFindSimilarRecordings # --------------------------------------------------------------------------- class TestMbFindSimilarRecordings: def setup_method(self): _mod._last_request = 0.0 def test_returns_dicts(self): data = {"recordings": [ { "title": "Song A", "artist-credit": [{"name": "Other Artist"}], }, { "title": "Song B", "artist-credit": [{"name": "Another Band"}], }, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Tool", ["rock", "metal"], ) assert len(result) == 2 assert result[0] == {"artist": "Other Artist", "title": "Song A"} assert result[1] == {"artist": "Another Band", "title": "Song B"} def test_excludes_original_artist(self): data = {"recordings": [ { "title": "Own Song", "artist-credit": [{"name": "Tool"}], }, { "title": "Other Song", "artist-credit": [{"name": "Deftones"}], }, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Tool", ["rock"], ) assert len(result) == 1 assert result[0]["artist"] == "Deftones" def test_excludes_original_artist_case_insensitive(self): data = {"recordings": [ { "title": "Song", "artist-credit": [{"name": "TOOL"}], }, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Tool", ["rock"], ) assert result == [] def test_deduplicates(self): data = {"recordings": [ { "title": "Song A", "artist-credit": [{"name": "Band X"}], }, { "title": "Song A", "artist-credit": [{"name": "Band X"}], }, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Other", ["rock"], ) assert len(result) == 1 def test_empty_tags(self): result = _mod.mb_find_similar_recordings("Tool", []) assert result == [] def test_no_recordings(self): with patch.object(_mod, "_mb_request", return_value={"recordings": []}): result = _mod.mb_find_similar_recordings( "Tool", ["rock"], ) assert result == [] def test_empty_response(self): with patch.object(_mod, "_mb_request", return_value={}): result = _mod.mb_find_similar_recordings( "Tool", ["rock"], ) assert result == [] def test_skips_missing_title(self): data = {"recordings": [ { "title": "", "artist-credit": [{"name": "Band"}], }, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Other", ["rock"], ) assert result == [] def test_skips_missing_artist_credit(self): data = {"recordings": [ {"title": "Song", "artist-credit": []}, {"title": "Song2"}, ]} with patch.object(_mod, "_mb_request", return_value=data): result = _mod.mb_find_similar_recordings( "Other", ["rock"], ) assert result == [] def test_uses_top_two_tags(self): """Query should use at most 2 tags.""" with patch.object(_mod, "_mb_request", return_value={}) as mock_req: _mod.mb_find_similar_recordings( "Tool", ["rock", "metal", "prog"], ) call_args = mock_req.call_args query = call_args[1]["query"] if "query" in (call_args[1] or {}) else call_args[0][1].get("query", "") # Verify the query contains both tag references assert "rock" in query or "metal" in query