371 lines
13 KiB
Python
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"))
|