import asyncio from typing import Optional from unittest.mock import AsyncMock, Mock, patch import pytest from app.api.endpoints import media as media_endpoint from app.api.endpoints import search as search_endpoint from app.chain import subscribe as subscribe_module from app.chain.subscribe import SubscribeChain from app.domain.context import MediaInfo from app.schemas.types import MediaSource, MediaType from app.schemas.media import normalize_media_source from app.schemas.workflow import MediaInfo as SchemaMediaInfo def test_media_source_normalization_accepts_plugin_source() -> None: """来源规范化应同时支持内置别名和插件扩展标识。""" assert normalize_media_source(" Plugin_Source ") == MediaSource("plugin_source") assert normalize_media_source("tmdb") == MediaSource.TMDB assert normalize_media_source("plugin source:invalid") is None def test_iqiyi_media_source_aliases_are_normalized() -> None: """爱奇艺探索来源的历史前缀和规范前缀应归一到同一媒体来源。""" assert normalize_media_source("iqiyi") is MediaSource.Iqiyi assert normalize_media_source("iqiyidiscover") is MediaSource.Iqiyi assert MediaSource("iqiyi") is MediaSource.Iqiyi def test_resolve_anilist_search_params_preserves_identity() -> None: """精确搜索参数应保留枚举来源和原生 ID。""" params, message = asyncio.run( search_endpoint._resolve_media_search_params( MediaSource.AniList, "154587", media_type=MediaType.TV, ) ) assert message == "" assert params == { "media_source": MediaSource.AniList, "media_id": "154587", } @pytest.mark.parametrize( ("media_source", "media_id"), [ (None, "154587"), (MediaSource.TMDB, ""), (MediaSource.TMDB, " "), (MediaSource.TMDB, "0"), ], ) def test_resource_search_rejects_invalid_identity_before_chain( monkeypatch, media_source: Optional[MediaSource], media_id: str, ) -> None: """半身份、空白或零值 ID 应在进入搜索链及其本地缓存前被拒绝。""" search_chain = Mock(side_effect=AssertionError("无效身份不应创建搜索链")) monkeypatch.setattr(search_endpoint, "SearchChain", search_chain) response = asyncio.run( search_endpoint.search_by_id( media_id=media_id, media_source=media_source, mtype=MediaType.MOVIE.value, _=None, ) ) assert not response.success assert response.message == "媒体ID格式无效" search_chain.assert_not_called() @pytest.mark.parametrize( ("media_source", "media_id"), [ (MediaSource.MusicBrainz, "release-group-1"), (MediaSource.TheAudioDB, "2109619"), (MediaSource.DoubanMusic, "1401853"), ], ) def test_resource_search_forwards_music_identity( monkeypatch, media_source: MediaSource, media_id: str, ) -> None: """音乐资源搜索应传递统一身份及音乐实体类型。""" captured = {} class FakeTorrent: """提供资源搜索响应需要的最小种子对象。""" @staticmethod def to_dict() -> dict: """返回可序列化的测试种子。""" return {"title": "Album result"} class FakeSearchChain: """记录精确资源搜索收到的参数。""" async def async_search_by_id(self, **kwargs): """保存搜索参数并返回单条测试结果。""" captured.update(kwargs) return [FakeTorrent()] monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain) response = asyncio.run( search_endpoint.search_by_id( media_id=media_id, media_source=media_source, mtype="music", music_type="album", _=None, ) ) assert response.success assert captured["media_source"] == media_source assert captured["media_id"] == media_id assert captured["mtype"] == MediaType.MUSIC assert captured["music_type"] == "album" def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None: """字幕精确搜索应把 AniList 统一身份传给搜索链。""" captured = {} class FakeSearchChain: """记录字幕搜索收到的参数。""" async def async_search_subtitles_by_id(self, **kwargs): """保存搜索参数并返回空结果。""" captured.update(kwargs) return [] monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain) source, message = asyncio.run( search_endpoint._build_subtitle_search_source( media_source=MediaSource.AniList, media_id="154587", mtype="tv", ) ) assert message == "" assert asyncio.run(source) == [] assert captured["media_source"] == MediaSource.AniList assert captured["media_id"] == "154587" def test_media_detail_forwards_unified_identity(monkeypatch) -> None: """媒体详情应只向识别链传递来源和原生 ID。""" media = MediaInfo( media_source=MediaSource.AniList, media_id="154587", type=MediaType.TV, title="Frieren", ) media_chain = Mock() media_chain.async_recognize_media = AsyncMock(return_value=media) media_chain.async_obtain_images = AsyncMock(return_value=None) monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) result = asyncio.run( media_endpoint.detail( media_id="154587", media_source=MediaSource.AniList, type_name=MediaType.TV.value, _=None, ) ) assert result["media_source"] == MediaSource.AniList.value assert result["media_id"] == "154587" media_chain.async_recognize_media.assert_awaited_once_with( media_source=MediaSource.AniList, media_id="154587", mtype=MediaType.TV, ) def test_media_detail_does_not_fallback_for_explicit_identity(monkeypatch) -> None: """明确身份识别失败时不能按标题切换到其他来源。""" media_chain = Mock() media_chain.async_recognize_media = AsyncMock(return_value=None) media_chain.async_recognize_by_meta = AsyncMock( side_effect=AssertionError("不应按标题切换识别源") ) monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) result = asyncio.run( media_endpoint.detail( media_id="999999", media_source=MediaSource.TMDB, type_name=MediaType.MOVIE.value, _=None, ) ) assert isinstance(result, SchemaMediaInfo) media_chain.async_recognize_by_meta.assert_not_awaited() def test_media_detail_rejects_zero_identity_before_chain(monkeypatch) -> None: """媒体详情的零值原生 ID 不得进入识别链。""" media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) result = asyncio.run( media_endpoint.detail( media_id="0", media_source=MediaSource.TMDB, type_name=MediaType.MOVIE.value, _=None, ) ) assert isinstance(result, SchemaMediaInfo) media_chain.assert_not_called() def test_media_seasons_rejects_zero_identity_before_external_chain(monkeypatch) -> None: """季信息接口收到零值身份时不得调用 TMDB 或通用识别链。""" tmdb_chain = Mock(side_effect=AssertionError("零值身份不应创建 TMDB 链")) media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) monkeypatch.setattr(media_endpoint, "TmdbChain", tmdb_chain) monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) result = asyncio.run( media_endpoint.seasons( media_source=MediaSource.TMDB, media_id="0", title="不应回退标题", _=None, ) ) assert result == [] tmdb_chain.assert_not_called() media_chain.assert_not_called() @pytest.mark.parametrize( ("endpoint", "kwargs"), [ (media_endpoint.groups, {"tmdbid": 0}), (media_endpoint.group_seasons, {"episode_group": "0"}), ], ) def test_explicit_tmdb_endpoints_reject_zero_before_external_chain( monkeypatch, endpoint, kwargs: dict, ) -> None: """本文件内的明确 TMDB 辅助入口不得把零值 ID 传给外部链。""" tmdb_chain = Mock(side_effect=AssertionError("零值身份不应创建 TMDB 链")) media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) monkeypatch.setattr(media_endpoint, "TmdbChain", tmdb_chain) monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) result = asyncio.run(endpoint(**kwargs, _=None)) assert result == [] tmdb_chain.assert_not_called() media_chain.assert_not_called() @pytest.mark.parametrize( ("media_source", "media_id", "media_kwargs", "episode_count"), [ ( MediaSource.Douban, "7301", {"douban_info": {"episodes_count": 12, "id": "7301", "subtype": "tv"}}, 12, ), ( MediaSource.Bangumi, "7302", {"bangumi_info": {"id": 7302, "platform": "TV", "total_episodes": 13}}, 13, ), ( MediaSource.AniList, "7303", {"anilist_info": {"episodes": 14, "format": "TV", "id": 7303}}, 14, ), ], ) def test_media_seasons_uses_source_episode_count( monkeypatch, media_source: MediaSource, media_id: str, media_kwargs: dict, episode_count: int, ) -> None: """非 TMDB 来源应使用自身集数构造季信息。""" media = MediaInfo(**media_kwargs) media_chain = Mock() media_chain.async_recognize_media = AsyncMock(return_value=media) monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) result = asyncio.run( media_endpoint.seasons( media_source=media_source, media_id=media_id, season=None, _=None, ) ) assert len(result) == 1 assert result[0].season_number == 1 assert result[0].episode_count == episode_count media_chain.async_recognize_media.assert_awaited_once_with( media_source=media_source, media_id=media_id, mtype=MediaType.TV, cache=False, ) def test_media_seasons_does_not_fallback_for_explicit_identity(monkeypatch) -> None: """明确身份查询失败时季信息接口应直接返回空列表。""" media_chain = Mock() media_chain.async_recognize_media = AsyncMock(return_value=None) media_chain.async_recognize_by_meta = AsyncMock( side_effect=AssertionError("不应按标题切换识别源") ) monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) result = asyncio.run( media_endpoint.seasons( media_source=MediaSource.AniList, media_id="7403", title="来源查询失败剧集", year="2026", _=None, ) ) assert result == [] media_chain.async_recognize_by_meta.assert_not_awaited() def test_subscribe_add_does_not_fallback_for_explicit_identity() -> None: """同步新增订阅的显式身份识别失败后不能按标题换源。""" media_chain = Mock() media_chain.recognize_media.return_value = None media_chain.recognize_by_meta.return_value = None chain = object.__new__(SubscribeChain) with patch.object(subscribe_module, "MediaChain", return_value=media_chain): sid, message = chain.add( title="AniList 同步订阅", year="2026", mtype=MediaType.TV, media_source=MediaSource.AniList, media_id="154587", ) assert sid is None assert message == "未识别到媒体信息" media_chain.recognize_media.assert_called_once() media_chain.recognize_by_meta.assert_not_called() def test_subscribe_async_add_does_not_fallback_for_explicit_identity() -> None: """异步新增订阅的显式身份识别失败后不能按标题换源。""" media_chain = Mock() media_chain.async_recognize_media = AsyncMock(return_value=None) media_chain.async_recognize_by_meta = AsyncMock(return_value=None) chain = object.__new__(SubscribeChain) with patch.object(subscribe_module, "MediaChain", return_value=media_chain): sid, message = asyncio.run( chain.async_add( title="AniList 异步订阅", year="2026", mtype=MediaType.TV, media_source=MediaSource.AniList, media_id="154587", ) ) assert sid is None assert message == "未识别到媒体信息" media_chain.async_recognize_media.assert_awaited_once() media_chain.async_recognize_by_meta.assert_not_awaited()