soulsync/tests/test_similar_artists_worker.py
BoulderBadgeDad 89e3486e84 Similar Artists enrichment worker (MusicMap → match → store) for library artists
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).

Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).

  - core/similar_artists_worker.py: pure seams (pick_source_artist_id,
    map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
    artists not yet source-matched; classifies not_found vs transient error
    (retry after 30d).
  - DB migration: similar_artists_match_status / _last_attempted on artists
    (mirrors every other source worker's tracking columns).
  - Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
    (opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
  - SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
    ('artists with / without similars') + Retry work; manual-match (inapplicable
    to a relationship) is gated out via relationship:true.
  - 10 seam tests; existing 80 enrichment tests still pass.

Note: keys under profile 1 (single-profile setups); multi-profile is future work.
2026-06-03 15:07:49 -07:00

127 lines
5.6 KiB
Python

"""Seam tests for the Similar-Artists enrichment worker's pure logic.
The worker fills the similar_artists table for LIBRARY artists (the watchlist
scanner only does watchlist artists). These tests exercise the import-light
seams in isolation — no DB, no MusicMap — via injected fakes:
- pick_source_artist_id → keying priority (and skip un-matched artists)
- map_payload_to_store_kwargs → MusicMap {id,source} → the right id column
- process_artist → fetch→match→store orchestration + status codes
"""
from __future__ import annotations
import core.similar_artists_worker as w
# --------------------------------------------------------------------------
# pick_source_artist_id — which id keys the artist's similars (must match the
# watchlist scanner's priority so both write the SAME source_artist_id).
# --------------------------------------------------------------------------
def test_pick_source_artist_id_priority():
row = {'spotify_artist_id': 'sp1', 'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}
assert w.pick_source_artist_id(row) == 'sp1' # spotify wins
assert w.pick_source_artist_id({'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}) == 'it1'
assert w.pick_source_artist_id({'deezer_id': 'dz1'}) == 'dz1'
assert w.pick_source_artist_id({'musicbrainz_id': 'mb1'}) == 'mb1'
def test_pick_source_artist_id_none_when_unmatched():
# Library artist not matched to any metadata source yet → skip (None).
assert w.pick_source_artist_id({'spotify_artist_id': None, 'itunes_artist_id': ''}) is None
assert w.pick_source_artist_id({}) is None
# --------------------------------------------------------------------------
# map_payload_to_store_kwargs — MusicMap payload {id, source} → store kwarg.
# --------------------------------------------------------------------------
def test_map_payload_each_source():
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'spotify'}) == {'similar_artist_spotify_id': 'x'}
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'itunes'}) == {'similar_artist_itunes_id': 'x'}
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'deezer'}) == {'similar_artist_deezer_id': 'x'}
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'musicbrainz'}) == {'similar_artist_musicbrainz_id': 'x'}
def test_map_payload_unknown_source_or_no_id():
# discogs has no column → name-only (empty kwargs), not a crash.
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'discogs'}) == {}
assert w.map_payload_to_store_kwargs({'source': 'spotify'}) == {} # no id
# --------------------------------------------------------------------------
# process_artist — fetch → store, status classification, keying.
# --------------------------------------------------------------------------
def _capture_store():
calls = []
def store(**kwargs):
calls.append(kwargs)
return True
return store, calls
def test_process_artist_matched_stores_with_keying():
store, calls = _capture_store()
payload = {
'success': True,
'similar_artists': [
{'name': 'B', 'id': 'sp_b', 'source': 'spotify', 'genres': ['rap'], 'popularity': 70},
{'name': 'C', 'id': 'it_c', 'source': 'itunes', 'image_url': 'http://x'},
],
}
status, count = w.process_artist('SRC1', 'A', lambda n, l: payload, store, limit=25, profile_id=1)
assert status == 'matched' and count == 2
# All similars keyed by the SOURCE artist id we passed (not the library PK).
assert all(c['source_artist_id'] == 'SRC1' for c in calls)
assert all(c['profile_id'] == 1 for c in calls)
# Provider id mapped to the right column; rank preserved in order.
assert calls[0]['similar_artist_spotify_id'] == 'sp_b' and calls[0]['similarity_rank'] == 1
assert calls[1]['similar_artist_itunes_id'] == 'it_c' and calls[1]['similarity_rank'] == 2
assert calls[0]['genres'] == ['rap'] and calls[0]['popularity'] == 70
def test_process_artist_not_found_when_no_matches():
store, calls = _capture_store()
status, count = w.process_artist('S', 'A', lambda n, l: {'success': True, 'similar_artists': []}, store)
assert status == 'not_found' and count == 0 and calls == []
def test_process_artist_not_found_on_404():
# Genuinely no MusicMap entry — shouldn't be retried as an error.
store, _ = _capture_store()
status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 404}, store)
assert status == 'not_found'
def test_process_artist_error_on_outage_is_retriable():
# 5xx / no providers → transient error (worker retries after retry_days).
store, _ = _capture_store()
status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 502}, store)
assert status == 'error'
def test_process_artist_error_when_fetch_raises():
store, _ = _capture_store()
def boom(n, l):
raise RuntimeError('musicmap down')
status, count = w.process_artist('S', 'A', boom, store)
assert status == 'error' and count == 0
def test_process_artist_skips_unstorable_but_counts_real():
# A store() that fails for one row shouldn't abort the rest.
calls = []
def store(**kwargs):
calls.append(kwargs)
return kwargs['similar_artist_name'] != 'B' # B fails to store
payload = {'success': True, 'similar_artists': [
{'name': 'B', 'id': '1', 'source': 'spotify'},
{'name': 'C', 'id': '2', 'source': 'spotify'},
]}
status, count = w.process_artist('S', 'A', lambda n, l: payload, store)
assert status == 'matched' and count == 1 and len(calls) == 2