"""RecommendationService (§6.5) — DB-free, in-memory fakes. Covers the two paths that matter: the ML recommender when available (reason ``ml``, order-preserving batched hydration) and the metadata fallback when it declines (similar/radio still work — the graceful-degradation invariant). """ import datetime as dt import uuid import pytest from app.application.recommendation_service import ( REASON_FROM_LIKES, REASON_ML, REASON_SIMILAR, RecommendationService, ) from app.domain.entities import Artist, Track from app.domain.errors import NotFoundError from app.infrastructure.ml.recommender import NullRecommender def _now() -> dt.datetime: return dt.datetime.now(dt.UTC) def _track(*, genre: str | None = "rock", artist_id: uuid.UUID | None = None) -> Track: now = _now() return Track( id=uuid.uuid4(), title="T", artist_id=artist_id or uuid.uuid4(), album_id=None, storage_uri="tracks/x.mp3", file_format="mp3", file_size=1, source="upload", source_id="x", duration_seconds=None, genre=genre, year=None, track_number=None, metadata_status="pending", metadata_error=None, enriched_at=None, availability="local", created_at=now, updated_at=now, ) def _artist() -> Artist: now = _now() return Artist( id=uuid.uuid4(), name="A", source=None, source_id=None, created_at=now, updated_at=now ) class FakeTrackRepo: def __init__(self, tracks: list[Track]) -> None: self._by_id = {t.id: t for t in tracks} self.similar: list[Track] = [] self.sample: list[Track] = [] self.similar_calls: list[dict[str, object]] = [] async def get_by_id(self, track_id: uuid.UUID) -> Track | None: return self._by_id.get(track_id) async def get_many(self, ids: list[uuid.UUID]) -> list[Track]: # Deliberately unordered (mirrors a real ``WHERE id IN`` query) so the # service is responsible for restoring request order. return [self._by_id[i] for i in reversed(ids) if i in self._by_id] async def list_similar(self, *, genre, artist_id, exclude_ids, limit) -> list[Track]: self.similar_calls.append({"exclude_ids": list(exclude_ids), "limit": limit}) return [t for t in self.similar if t.id not in exclude_ids][:limit] async def sample_playable(self, *, exclude_ids, limit) -> list[Track]: return [t for t in self.sample if t.id not in exclude_ids][:limit] class FakeArtistRepo: def __init__(self, artists: list[Artist]) -> None: self._by_id = {a.id: a for a in artists} self.similar: list[Artist] = [] async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: return self._by_id.get(artist_id) async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: return [self._by_id[i] for i in reversed(ids) if i in self._by_id] async def list_similar(self, *, artist_id, limit) -> list[Artist]: return self.similar[:limit] class FakeLikeRepo: def __init__(self, liked: list[Track]) -> None: self.liked = liked async def list_liked_tracks(self, *, user_id, limit, offset) -> list[Track]: return self.liked[offset : offset + limit] class StubRecommender: """An 'available' ML recommender returning fixed ids for the ML path.""" def __init__(self, ids: list[uuid.UUID] | None) -> None: self._ids = ids def is_available(self) -> bool: return True async def similar_track_ids(self, track_id, *, limit, exclude_ids): return self._ids async def similar_artist_ids(self, artist_id, *, limit): return self._ids async def radio_track_ids(self, *, seed_track_id, exploration, limit, exclude_ids): return self._ids def _service(tracks, artists, likes, recommender) -> RecommendationService: return RecommendationService( recommender=recommender, tracks=tracks, artists=artists, likes=likes ) # -- similar ------------------------------------------------------------------ async def test_similar_tracks_unknown_seed_raises() -> None: svc = _service(FakeTrackRepo([]), FakeArtistRepo([]), FakeLikeRepo([]), NullRecommender()) with pytest.raises(NotFoundError): await svc.similar_tracks(uuid.uuid4(), limit=5) async def test_similar_tracks_falls_back_to_metadata() -> None: seed = _track() neighbours = [_track(), _track()] repo = FakeTrackRepo([seed, *neighbours]) repo.similar = neighbours svc = _service(repo, FakeArtistRepo([]), FakeLikeRepo([]), NullRecommender()) reason, found = await svc.similar_tracks(seed.id, limit=5) assert reason == REASON_SIMILAR assert [t.id for t in found] == [n.id for n in neighbours] # The seed itself is always excluded from its own neighbours. assert repo.similar_calls[-1]["exclude_ids"] == [seed.id] async def test_similar_tracks_uses_ml_and_preserves_order() -> None: seed = _track() a, b, c = _track(), _track(), _track() repo = FakeTrackRepo([seed, a, b, c]) # ML returns a specific order; hydration must preserve it despite get_many # returning rows unordered, and skip ids that no longer exist. ml_ids = [c.id, uuid.uuid4(), a.id, b.id] svc = _service(repo, FakeArtistRepo([]), FakeLikeRepo([]), StubRecommender(ml_ids)) reason, found = await svc.similar_tracks(seed.id, limit=10) assert reason == REASON_ML assert [t.id for t in found] == [c.id, a.id, b.id] async def test_similar_artists_falls_back_to_metadata() -> None: seed = _artist() neighbours = [_artist(), _artist()] repo = FakeArtistRepo([seed]) repo.similar = neighbours svc = _service(FakeTrackRepo([]), repo, FakeLikeRepo([]), NullRecommender()) reason, found = await svc.similar_artists(seed.id, limit=5) assert reason == REASON_SIMILAR assert [a.id for a in found] == [n.id for n in neighbours] # -- radio -------------------------------------------------------------------- async def test_radio_from_likes_seeds_and_fills(monkeypatch) -> None: liked = _track() similar = [_track(), _track()] explore = [_track(), _track(), _track()] repo = FakeTrackRepo([liked, *similar, *explore]) repo.similar = similar repo.sample = explore svc = _service(repo, FakeArtistRepo([]), FakeLikeRepo([liked]), NullRecommender()) # Deterministic seed choice + no shuffling for a stable assertion. monkeypatch.setattr("app.application.recommendation_service.random.choice", lambda s: s[0]) monkeypatch.setattr("app.application.recommendation_service.random.shuffle", lambda s: None) reason, picks = await svc.radio( user_id=uuid.uuid4(), seed_track_id=None, from_likes=True, exploration=0.5, limit=4, exclude_ids=[], ) assert reason == "metadata" ids = [p.track.id for p in picks] # 4 picks, all distinct, no seed repeated, mix of similar + discover. assert len(ids) == 4 assert len(set(ids)) == 4 reasons = {p.reason for p in picks} assert REASON_FROM_LIKES in reasons # similar picks carry the seed's reason async def test_radio_respects_exclude_ids(monkeypatch) -> None: similar = [_track(), _track()] explore = [_track(), _track()] repo = FakeTrackRepo([*similar, *explore]) repo.similar = similar repo.sample = explore svc = _service(repo, FakeArtistRepo([]), FakeLikeRepo([]), NullRecommender()) monkeypatch.setattr("app.application.recommendation_service.random.shuffle", lambda s: None) excluded = similar[0].id _, picks = await svc.radio( user_id=uuid.uuid4(), seed_track_id=None, from_likes=False, exploration=1.0, limit=4, exclude_ids=[excluded], ) assert excluded not in {p.track.id for p in picks} async def test_radio_uses_ml_when_available(monkeypatch) -> None: a, b = _track(), _track() repo = FakeTrackRepo([a, b]) svc = _service(repo, FakeArtistRepo([]), FakeLikeRepo([]), StubRecommender([b.id, a.id])) monkeypatch.setattr("app.application.recommendation_service.random.shuffle", lambda s: None) reason, picks = await svc.radio( user_id=uuid.uuid4(), seed_track_id=a.id, from_likes=False, exploration=0.3, limit=5, exclude_ids=[], ) assert reason == REASON_ML assert [p.track.id for p in picks] == [b.id, a.id] assert all(p.reason == REASON_ML for p in picks)