Files
musicseerr/backend/tests/repositories/test_coverart_audiodb_provider.py
2026-04-03 15:53:00 +01:00

371 lines
13 KiB
Python

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from infrastructure.queue.priority_queue import RequestPriority
from repositories.audiodb_models import AudioDBAlbumImages, AudioDBArtistImages
from repositories.coverart_album import AlbumCoverFetcher
from repositories.coverart_artist import ArtistImageFetcher, TransientImageFetchError
def _response(
status_code: int = 200,
content_type: str = "image/jpeg",
content: bytes = b"img",
) -> MagicMock:
response = MagicMock()
response.status_code = status_code
response.headers = {"content-type": content_type}
response.content = content
return response
@pytest.mark.asyncio
async def test_album_fetch_from_audiodb_downloads_and_writes_cache():
http_get = AsyncMock(return_value=_response())
write_cache = AsyncMock()
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_album_images = AsyncMock(
return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg")
)
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=write_cache,
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin"))
assert result == (b"img", "image/jpeg", "audiodb")
audiodb_service.fetch_and_cache_album_images.assert_awaited_once_with("release-group-id")
http_get.assert_awaited_once()
await asyncio.sleep(0)
assert write_cache.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cached_value",
[
None,
AudioDBAlbumImages(is_negative=True),
AudioDBAlbumImages(album_thumb_url=None),
],
)
async def test_album_fetch_from_audiodb_skips_when_cache_not_usable(cached_value):
http_get = AsyncMock(return_value=_response())
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_album_images = AsyncMock(return_value=cached_value)
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin"))
assert result is None
http_get.assert_not_awaited()
@pytest.mark.asyncio
async def test_album_fetch_from_audiodb_returns_none_when_service_missing():
fetcher = AlbumCoverFetcher(
http_get_fn=AsyncMock(return_value=_response()),
write_cache_fn=AsyncMock(),
audiodb_service=None,
)
result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin"))
assert result is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,content_type",
[
(404, "image/jpeg"),
(200, "application/json"),
],
)
async def test_album_fetch_from_audiodb_returns_none_for_invalid_download(status_code, content_type):
http_get = AsyncMock(return_value=_response(status_code=status_code, content_type=content_type))
write_cache = AsyncMock()
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_album_images = AsyncMock(
return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg")
)
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=write_cache,
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin"))
assert result is None
await asyncio.sleep(0)
assert write_cache.await_count == 0
@pytest.mark.asyncio
async def test_album_fetch_from_audiodb_returns_none_on_exception():
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_album_images = AsyncMock(
return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg")
)
http_get = AsyncMock(side_effect=RuntimeError("boom"))
write_cache = AsyncMock()
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=write_cache,
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin"))
assert result is None
await asyncio.sleep(0)
assert write_cache.await_count == 0
@pytest.mark.asyncio
async def test_release_cover_skips_audiodb_when_release_group_id_unavailable():
http_get = AsyncMock(return_value=_response(content=b"cover"))
mb_repo = MagicMock()
mb_repo.get_release_group_id_from_release = AsyncMock(return_value=None)
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_album_images = AsyncMock()
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
mb_repo=mb_repo,
audiodb_service=audiodb_service,
)
fetcher._fetch_release_local_sources = AsyncMock(return_value=None)
result = await fetcher.fetch_release_cover("release-id", None, Path("/tmp/release.bin"))
assert result == (b"cover", "image/jpeg", "cover-art-archive")
audiodb_service.fetch_and_cache_album_images.assert_not_awaited()
@pytest.mark.asyncio
async def test_album_release_group_chain_uses_audiodb_before_coverartarchive():
http_get = AsyncMock()
fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=MagicMock())
fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None)
fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb"))
result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin"))
assert result is not None and result[2] == "audiodb"
fetcher._fetch_from_audiodb.assert_awaited_once_with("release-group-id", Path("/tmp/album.bin"), priority=RequestPriority.IMAGE_FETCH)
http_get.assert_not_awaited()
@pytest.mark.asyncio
async def test_album_release_group_chain_falls_back_to_coverartarchive_when_audiodb_misses():
http_get = AsyncMock(return_value=_response(content=b"cover"))
fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=MagicMock())
fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None)
fetcher._fetch_from_audiodb = AsyncMock(return_value=None)
fetcher._get_cover_from_best_release = AsyncMock(return_value=None)
result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin"))
assert result == (b"cover", "image/jpeg", "cover-art-archive")
fetcher._fetch_from_audiodb.assert_awaited_once()
@pytest.mark.asyncio
async def test_release_cover_uses_audiodb_before_coverartarchive():
http_get = AsyncMock()
mb_repo = MagicMock()
mb_repo.get_release_group_id_from_release = AsyncMock(return_value="release-group-id")
fetcher = AlbumCoverFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
mb_repo=mb_repo,
audiodb_service=MagicMock(),
)
fetcher._fetch_release_local_sources = AsyncMock(return_value=None)
fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb"))
result = await fetcher.fetch_release_cover("release-id", None, Path("/tmp/release.bin"))
assert result is not None and result[2] == "audiodb"
fetcher._fetch_from_audiodb.assert_awaited_once_with("release-group-id", Path("/tmp/release.bin"), priority=RequestPriority.IMAGE_FETCH)
http_get.assert_not_awaited()
@pytest.mark.asyncio
async def test_release_group_cover_skips_audiodb_when_service_missing():
http_get = AsyncMock(return_value=_response(content=b"cover"))
fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=None)
fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None)
fetcher._get_cover_from_best_release = AsyncMock(return_value=None)
result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin"))
assert result == (b"cover", "image/jpeg", "cover-art-archive")
@pytest.mark.asyncio
async def test_artist_fetch_from_audiodb_downloads_and_writes_cache():
http_get = AsyncMock(return_value=_response())
write_cache = AsyncMock()
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_artist_images = AsyncMock(
return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg")
)
fetcher = ArtistImageFetcher(
http_get_fn=http_get,
write_cache_fn=write_cache,
cache=MagicMock(),
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin"))
assert result == (b"img", "image/jpeg", "audiodb")
audiodb_service.fetch_and_cache_artist_images.assert_awaited_once_with("artist-id")
http_get.assert_awaited_once()
await asyncio.sleep(0)
assert write_cache.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cached_value",
[
None,
AudioDBArtistImages(is_negative=True),
AudioDBArtistImages(thumb_url=None),
],
)
async def test_artist_fetch_from_audiodb_skips_when_cache_not_usable(cached_value):
http_get = AsyncMock(return_value=_response())
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_artist_images = AsyncMock(return_value=cached_value)
fetcher = ArtistImageFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin"))
assert result is None
http_get.assert_not_awaited()
@pytest.mark.asyncio
async def test_artist_fetch_from_audiodb_reraises_transient_exception():
http_get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_artist_images = AsyncMock(
return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg")
)
fetcher = ArtistImageFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=audiodb_service,
)
with pytest.raises(httpx.TimeoutException):
await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin"))
@pytest.mark.asyncio
async def test_artist_fetch_from_audiodb_handles_non_transient_exception():
http_get = AsyncMock(side_effect=RuntimeError("boom"))
audiodb_service = MagicMock()
audiodb_service.fetch_and_cache_artist_images = AsyncMock(
return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg")
)
fetcher = ArtistImageFetcher(
http_get_fn=http_get,
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=audiodb_service,
)
result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin"))
assert result is None
@pytest.mark.asyncio
async def test_artist_chain_uses_audiodb_before_wikidata():
fetcher = ArtistImageFetcher(
http_get_fn=AsyncMock(),
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=MagicMock(),
)
fetcher._fetch_local_sources = AsyncMock(return_value=(None, False))
fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb"))
fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata"))
result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin"))
assert result is not None and result[2] == "audiodb"
fetcher._fetch_from_wikidata.assert_not_awaited()
@pytest.mark.asyncio
async def test_artist_chain_falls_back_to_wikidata_after_audiodb_miss():
fetcher = ArtistImageFetcher(
http_get_fn=AsyncMock(),
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=MagicMock(),
)
fetcher._fetch_local_sources = AsyncMock(return_value=(None, False))
fetcher._fetch_from_audiodb = AsyncMock(return_value=None)
fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata"))
result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin"))
assert result is not None and result[2] == "wikidata"
fetcher._fetch_from_wikidata.assert_awaited_once()
@pytest.mark.asyncio
async def test_artist_chain_skips_audiodb_when_service_missing():
fetcher = ArtistImageFetcher(
http_get_fn=AsyncMock(),
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=None,
)
fetcher._fetch_local_sources = AsyncMock(return_value=(None, False))
fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata"))
result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin"))
assert result is not None and result[2] == "wikidata"
@pytest.mark.asyncio
async def test_artist_chain_raises_transient_when_audiodb_fails_transiently_and_no_fallback_result():
fetcher = ArtistImageFetcher(
http_get_fn=AsyncMock(),
write_cache_fn=AsyncMock(),
cache=MagicMock(),
audiodb_service=MagicMock(),
)
fetcher._fetch_local_sources = AsyncMock(return_value=(None, False))
fetcher._fetch_from_audiodb = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
fetcher._fetch_from_wikidata = AsyncMock(return_value=None)
with pytest.raises(TransientImageFetchError):
await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin"))