Files
mcma-backend/tests/test_recommendation_service.py
Цвылев Александр Вадимович fb7827d09c 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>
2026-07-29 11:07:22 +03:00

249 lines
8.4 KiB
Python

"""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)