test: cover lyrics, transcode, and recommendation
DB-free unit tests for the three previously-untested features: - lyrics: get-or-fetch caching, not_found TTL, force refetch, graceful miss; plus the LRC -> structured-lyrics serializer (timing, plain fallback, empty) - transcode: path helpers, segment-name traversal guard, cache hit/miss, the unknown/not-downloaded 404 paths - reco: ML path (order-preserving batched hydration) + metadata fallback for similar/radio, exclude handling, reason codes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user