"""Unit tests for RemoteLibraryService — DB-free, in-memory fakes (plan: Model C remote browse + lazy materialization).""" import datetime as dt import uuid import pytest from app.application.remote_library_service import RemoteLibraryService from app.domain.entities import Artist, DownloadJob, Track from app.domain.errors import NotFoundError, ValidationError pytestmark = pytest.mark.asyncio class FakeArtistRepo: def __init__(self) -> None: self._by_name: dict[str, Artist] = {} async def get_or_create(self, name: str) -> Artist: if name not in self._by_name: now = dt.datetime.now(dt.UTC) self._by_name[name] = Artist( id=uuid.uuid4(), name=name, source=None, source_id=None, created_at=now, updated_at=now, ) return self._by_name[name] class FakeTrackRepo: def __init__(self) -> None: self.by_id: dict[uuid.UUID, Track] = {} self.by_source: dict[tuple[str, str], Track] = {} async def get_by_id(self, track_id: uuid.UUID) -> Track | None: return self.by_id.get(track_id) async def get_by_source(self, source: str, source_id: str) -> Track | None: return self.by_source.get((source, source_id)) async def add(self, **kw: object) -> Track: now = dt.datetime.now(dt.UTC) track = Track( id=kw["id"], # type: ignore[arg-type] title=str(kw["title"]), artist_id=kw["artist_id"], # type: ignore[arg-type] album_id=None, storage_uri=kw["storage_uri"], # type: ignore[arg-type] file_format=kw["file_format"], # type: ignore[arg-type] file_size=kw["file_size"], # type: ignore[arg-type] source=str(kw["source"]), source_id=str(kw["source_id"]), duration_seconds=None, genre=None, year=None, track_number=None, metadata_status=str(kw["metadata_status"]), metadata_error=None, enriched_at=None, availability=str(kw["availability"]), created_at=now, updated_at=now, ) self.by_id[track.id] = track self.by_source[(track.source, track.source_id)] = track return track def _local_track(source: str = "youtube", source_id: str = "local-1") -> Track: now = dt.datetime.now(dt.UTC) return Track( id=uuid.uuid4(), title="Already Here", artist_id=uuid.uuid4(), album_id=None, storage_uri="tracks/aa/aa.m4a", file_format="m4a", file_size=123, source=source, source_id=source_id, duration_seconds=None, genre=None, year=None, track_number=None, metadata_status="pending", metadata_error=None, enriched_at=None, availability="local", created_at=now, updated_at=now, ) class FakeJobRepo: def __init__(self) -> None: self.jobs: dict[uuid.UUID, DownloadJob] = {} self.active: dict[tuple[str, str], DownloadJob] = {} async def add(self, **kw: object) -> DownloadJob: now = dt.datetime.now(dt.UTC) job = DownloadJob( id=uuid.uuid4(), source=str(kw["source"]), source_id=kw.get("source_id"), # type: ignore[arg-type] query=kw.get("query"), # type: ignore[arg-type] requested_by=kw.get("requested_by"), # type: ignore[arg-type] status="queued", progress=0.0, error_message=None, retry_count=0, track_id=None, created_at=now, updated_at=now, ) self.jobs[job.id] = job return job async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None: return self.jobs.get(job_id) async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None: return self.active.get((source, source_id)) async def set_status(self, job_id: uuid.UUID, **kw: object) -> None: job = self.jobs[job_id] track_id = kw.get("track_id") if track_id is not None: self.jobs[job_id] = DownloadJob( id=job.id, source=job.source, source_id=job.source_id, query=job.query, requested_by=job.requested_by, status=str(kw.get("status", job.status)), progress=job.progress, error_message=job.error_message, retry_count=job.retry_count, track_id=track_id, # type: ignore[arg-type] created_at=job.created_at, updated_at=job.updated_at, ) def _service( *, tracks: FakeTrackRepo, artists: FakeArtistRepo, jobs: FakeJobRepo, enqueued: list[uuid.UUID], ) -> RemoteLibraryService: async def enqueue_materialize(job_id: uuid.UUID) -> None: enqueued.append(job_id) return RemoteLibraryService( tracks=tracks, # type: ignore[arg-type] artists=artists, # type: ignore[arg-type] jobs=jobs, # type: ignore[arg-type] enqueue_materialize=enqueue_materialize, ) async def test_save_remote_creates_placeholder() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) track = await service.save_remote( source="youtube", source_id="abc", title="Bohemian Rhapsody", artist="Queen", added_by=None ) assert track.availability == "remote" assert track.storage_uri is None assert track.file_format is None assert track.source == "youtube" assert track.source_id == "abc" async def test_save_remote_is_idempotent() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) first = await service.save_remote( source="youtube", source_id="abc", title="A", artist="Queen", added_by=None ) second = await service.save_remote( source="youtube", source_id="abc", title="B", artist="Other", added_by=None ) assert first.id == second.id assert second.title == "A" # untouched by the second call async def test_materialize_already_local_is_noop() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] track = _local_track() tracks.by_id[track.id] = track service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) outcome = await service.request_materialize(track.id, requested_by=None) assert outcome.job is None assert outcome.track.id == track.id assert enq == [] async def test_materialize_remote_creates_and_enqueues_job() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) track = await service.save_remote( source="youtube", source_id="abc", title="A", artist="Queen", added_by=None ) outcome = await service.request_materialize(track.id, requested_by=None) assert outcome.job is not None assert outcome.job.source == "youtube" assert outcome.job.source_id == "abc" assert outcome.job.track_id == track.id assert enq == [outcome.job.id] async def test_materialize_reuses_active_job() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) track = await service.save_remote( source="youtube", source_id="abc", title="A", artist="Queen", added_by=None ) existing = await jobs.add(source="youtube", source_id="abc", query=None, requested_by=None) jobs.active[("youtube", "abc")] = existing outcome = await service.request_materialize(track.id, requested_by=None) assert outcome.job is not None assert outcome.job.id == existing.id assert enq == [] # not re-enqueued async def test_materialize_missing_track_raises() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) with pytest.raises(NotFoundError): await service.request_materialize(uuid.uuid4(), requested_by=None) async def test_save_remote_requires_source_id() -> None: tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), [] service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq) with pytest.raises(ValidationError): await service.save_remote( source="youtube", source_id=" ", title="A", artist=None, added_by=None )