import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from fastapi import HTTPException from app.api.endpoints import media as media_endpoints from app.api.endpoints.music import ( explore_music, music_album, music_album_related, music_artist, music_artist_albums, music_artist_related, recognize_music, ) from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease from app.schemas.music import MusicRecognizeRequest from app.schemas.types import MediaSource, MediaType def test_music_routes_are_registered(): """V1 API 应注册音乐详情识别、探索及艺术家专辑浏览路由。""" from app.api.router_specs import API_V1_ROUTER_SPECS routes = { (f"{spec.prefix}{route.path}", tuple(route.methods or [])) for spec in API_V1_ROUTER_SPECS for route in spec.router.routes if spec.prefix == "/music" or spec.prefix == "/media" or spec.prefix == "/recommend" } assert any(path == "/music/recognize" and "POST" in methods for path, methods in routes) assert any(path == "/music/explore" and "GET" in methods for path, methods in routes) assert any(path == "/music/album/{album_id}" and "GET" in methods for path, methods in routes) assert any( path == "/music/album/{album_id}/related" and "GET" in methods for path, methods in routes ) assert any(path == "/music/artist/{artist_id}" and "GET" in methods for path, methods in routes) assert any( path == "/music/artist/{artist_id}/albums" and "GET" in methods for path, methods in routes ) assert any( path == "/music/artist/{artist_id}/related" and "GET" in methods for path, methods in routes ) assert any( path == "/media/search" and "GET" in methods for path, methods in routes ) for recommend_path in ("/recommend/music_douban",): assert any( path == recommend_path and "GET" in methods for path, methods in routes ) assert not any( path.startswith("/recommend/music_theaudiodb") for path, _methods in routes ) def test_media_search_routes_music_queries_with_query_kwarg(): """统一媒体搜索的音乐分支应以关键字参数调用 MediaChain。""" chain = Mock() chain.async_search_music = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", media_id="recording-1", music_type="recording", title="晴天", artists=["周杰伦"], release_date="2003-07-31", category="Album / Studio", ) ] ) with ( patch.object(media_endpoints, "MediaChain", return_value=chain) as media_chain, ): result = asyncio.run( media_endpoints.search( title="晴天", type="music", count=30, page=1, _=Mock(), ) ) assert result[0]["media_id"] == "recording-1" assert result[0]["music_type"] == "recording" assert result[0]["title"] == "晴天" chain.async_search_music.assert_awaited_once_with(query="晴天", limit=30) media_chain.assert_called_once() def test_media_search_forwards_explicit_music_source(): """统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。""" chain = Mock() chain.async_search_music = AsyncMock(return_value=[]) with patch.object(media_endpoints, "MediaChain", return_value=chain): result = asyncio.run( media_endpoints.search( title="Coldplay", type="music", count=20, media_source="theaudiodb", _=Mock(), ) ) assert result == [] chain.async_search_music.assert_awaited_once_with( query="Coldplay", limit=20, media_source=(MediaSource.TheAudioDB,), ) def test_recognize_music_returns_detail(): """音乐识别接口应按来源和 ID 经统一识别入口返回详情。""" from app.chain.media import MediaChain chain = Mock() chain.async_recognize_media = AsyncMock( return_value=MusicInfo( media_source="musicbrainz", media_id="recording-1", title="晴天", ) ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run( recognize_music( request=MusicRecognizeRequest( media_source="musicbrainz", media_id="recording-1", music_type="recording", ), _=Mock(), ) ) assert result.media_id == "recording-1" chain.async_recognize_media.assert_awaited_once_with( media_source="musicbrainz", media_id="recording-1", mtype=MediaType.MUSIC, music_type="recording", ) def test_recognize_music_returns_404_for_unknown_item(): """音乐详情不存在时接口应返回 404。""" from app.chain.media import MediaChain chain = Mock() chain.async_recognize_media = AsyncMock(return_value=None) with ( patch("app.api.endpoints.music.MediaChain", return_value=chain), pytest.raises(HTTPException) as error, ): asyncio.run( recognize_music( request=MusicRecognizeRequest(media_source="musicbrainz", media_id="missing"), _=Mock(), ) ) assert error.value.status_code == 404 def test_explore_music_forwards_filters_and_serializes_chart(): """音乐探索接口应传递实体、周期、排序、热度和封面筛选条件。""" chain = Mock() chain.async_music_chart = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", media_id="recording-1", title="晴天", artists=["周杰伦"], listen_count=123, ) ] ) with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( page=2, count=20, range_name="this_week", sort_by="listen_count.asc", min_listen_count=100, with_cover=True, _=Mock(), ) ) assert result[0].listen_count == 123 chain.async_music_chart.assert_awaited_once_with( range_name="this_week", page=2, count=20, sort_by="listen_count.asc", min_listen_count=100, with_cover=True, entity="recording", ) def test_explore_music_supports_official_fresh_release_mode(): """新发行模式应按 ListenBrainz 官方排序和时间窗口请求探索数据。""" chain = Mock() chain.async_music_fresh_releases = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", media_id="release-group-1", music_type="album", title="ARIRANG", artists=["BTS"], ) ] ) with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( page=1, count=30, mode="fresh", sort="artist_credit_name", days=30, past=True, future=False, with_cover=True, _=Mock(), ) ) assert result[0].music_type == "album" chain.async_music_fresh_releases.assert_awaited_once_with( days=30, sort="artist_credit_name", past=True, future=False, page=1, count=30, with_cover=True, ) def test_explore_music_forces_douban_music_to_tag_browsing(): """豆瓣音乐探索即使收到榜单模式也应固定分类浏览,不与推荐页重复。""" chain = Mock() chain.async_music_discover = AsyncMock( return_value=[ MusicInfo( media_source="doubanmusic", media_id="album-1", music_type="album", title="范特西", ) ] ) with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( media_source="doubanmusic", entity="album", mode="chart", tags="流行,华语", douban_sort="S", page=2, count=20, _=Mock(), ) ) assert result[0].media_source == "doubanmusic" chain.async_music_discover.assert_awaited_once_with( media_source=MediaSource.DoubanMusic, page=2, count=20, entity="album", mode="tag", tags="流行,华语", sort="S", ) def test_explore_music_filters_missing_covers_for_external_sources(): """外部音乐源选择仅有封面时应在统一响应层过滤无图条目。""" chain = Mock() chain.async_music_discover = AsyncMock( return_value=[ MusicInfo(media_source="doubanmusic", media_id="album-1", title="No Cover"), MusicInfo( media_source="doubanmusic", media_id="album-2", title="With Cover", cover_url="https://img.example/album-2.jpg", ), ] ) with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( media_source="doubanmusic", with_cover=True, _=Mock(), ) ) assert [item.media_id for item in result] == ["album-2"] def test_music_album_returns_tracks_and_releases(): """专辑接口应返回专辑详情、曲目和发行版本。""" chain = Mock() chain.async_get_music_album = AsyncMock( return_value=MusicAlbumInfo( media_source="musicbrainz", media_id="release-group-1", title="A Night at the Opera", artists=["Queen"], artist_ids=["artist-1"], album_type="Album", release_date="1975-11-21", tracks=[MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Love of My Life")], releases=[MusicRelease(media_id="release-1", title="A Night at the Opera", date="1975")], ) ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_album(album_id="release-group-1", _=Mock())) assert result.music_type == "album" assert result.year == 1975 assert result.total_tracks == 1 assert result.tracks[0].media_id == "recording-1" assert result.releases[0].media_id == "release-1" chain.async_get_music_album.assert_awaited_once_with( media_source=MediaSource.MusicBrainz, media_id="release-group-1" ) def test_music_album_returns_404_for_unknown_album(): """专辑不存在时接口应返回 404。""" chain = Mock() chain.async_get_music_album = AsyncMock(return_value=None) with ( patch("app.api.endpoints.music.MediaChain", return_value=chain), pytest.raises(HTTPException) as error, ): asyncio.run(music_album(album_id="missing", _=Mock())) assert error.value.status_code == 404 def test_music_album_related_returns_source_results(): """专辑关联浏览接口应传递来源和数量并序列化结果。""" chain = Mock() chain.async_get_music_album_related = AsyncMock( return_value=[ MusicInfo( media_source="doubanmusic", media_id="album-2", music_type="album", title="依然范特西", ) ] ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run( music_album_related( album_id="album-1", count=12, media_source="doubanmusic", _=Mock(), ) ) assert result[0].media_id == "album-2" chain.async_get_music_album_related.assert_awaited_once_with( media_source=MediaSource.DoubanMusic, media_id="album-1", count=12, ) def test_music_artist_returns_detail(): """艺术家接口应返回名称、类型和活跃时间。""" chain = Mock() chain.async_get_music_artist = AsyncMock( return_value=MusicArtistInfo( media_source="musicbrainz", media_id="artist-1", name="Queen", artist_type="Group", begin_date="1970-06-27", ) ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_artist(artist_id="artist-1", _=Mock())) assert result.name == "Queen" assert result.title == "Queen" assert result.music_type == "artist" chain.async_get_music_artist.assert_awaited_once_with( media_source=MediaSource.MusicBrainz, media_id="artist-1" ) def test_music_artist_albums_forwards_pagination_and_type(): """艺术家专辑接口应传递分页和专辑类型筛选。""" chain = Mock() chain.async_get_music_artist_albums = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", media_id="release-group-1", music_type="album", title="News of the World", ) ] ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run( music_artist_albums(artist_id="artist-1", page=2, count=10, album_type="ep", _=Mock()) ) assert result[0].media_id == "release-group-1" chain.async_get_music_artist_albums.assert_awaited_once_with( media_source=MediaSource.MusicBrainz, media_id="artist-1", page=2, count=10, album_type="ep", ) def test_music_artist_related_returns_relationship_text(): """关联艺术家接口应返回关系说明,供详情页展示。""" chain = Mock() chain.async_get_music_artist_related = AsyncMock( return_value=[ MusicArtistInfo( media_source="musicbrainz", media_id="artist-2", name="Freddie Mercury", relation="member of band", ) ] ) with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_artist_related(artist_id="artist-1", count=5, _=Mock())) assert result[0].relation == "member of band" chain.async_get_music_artist_related.assert_awaited_once_with( media_source=MediaSource.MusicBrainz, media_id="artist-1", count=5, )