"""MusicBrainz 音乐识别本地持久化缓存测试。 覆盖缓存键生成、读写回环、负缓存、持久化恢复与保存、管理端点 统计与权限,以及 MusicBrainzModule 识别流程对缓存的命中与回填。 """ import asyncio import inspect import pickle from unittest.mock import Mock from app.api.endpoints import music as music_endpoint from app.core.context import MusicInfo from app.core.meta import MetaMusic from app.db.user_oper import get_current_active_superuser_async from app.modules.musicbrainz import music_cache as music_cache_module from app.modules.musicbrainz import MusicBrainzModule from app.modules.musicbrainz.music_cache import MusicBrainzCache class _MemoryCacheStub: """提供音乐缓存管理测试所需的最小内存后端。""" def __init__(self, data: dict): """使用给定字典初始化测试缓存。""" self.data = data @staticmethod def is_redis() -> bool: """测试替身固定使用非 Redis 后端。""" return False def items(self): """返回全部缓存条目。""" return self.data.items() def get(self, key: str): """读取指定缓存条目。""" return self.data.get(key) def delete(self, key: str): """删除指定缓存条目。""" self.data.pop(key, None) def set(self, key: str, value, ttl=None): """写入指定缓存条目。""" self.data[key] = value def clear(self): """清空全部缓存条目。""" self.data.clear() class _TTLCacheStub(_MemoryCacheStub): """记录每条数据恢复时剩余 TTL 的内存缓存替身。""" def __init__(self): """初始化空缓存和 TTL 记录。""" super().__init__({}) self.ttls = {} def set(self, key: str, value, ttl=None): """写入缓存并记录本次设置的 TTL。""" super().set(key, value, ttl=ttl) self.ttls[key] = ttl class _FileCacheStub: """提供音乐缓存持久化测试所需的统一文件缓存替身。""" def __init__(self, content: bytes = None): """使用预置序列化内容初始化文件缓存。""" self.content = content self.set_calls = [] self.delete_calls = [] def get(self, key: str, region: str): """读取预置缓存内容。""" return self.content def set(self, key: str, value: bytes, region: str): """记录统一文件缓存写入。""" self.content = value self.set_calls.append((key, region)) def delete(self, key: str, region: str): """记录统一文件缓存删除。""" self.content = None self.delete_calls.append((key, region)) def _build_music_cache(data: dict) -> MusicBrainzCache: """构造绕过单例初始化的音乐识别缓存测试实例。""" cache = object.__new__(MusicBrainzCache) cache.maxsize = 256 cache.ttl = 3600 cache.region = "__musicbrainz_cache__" cache._cache = _MemoryCacheStub(data) cache._expires_at = {key: float("inf") for key in data} cache._dirty = False cache._file_cache = None cache.save = lambda force=False: None return cache def _build_initialized_music_cache(monkeypatch, file_cache: _FileCacheStub, runtime_cache: _TTLCacheStub, now: float = 1000) -> MusicBrainzCache: """使用可控时间和缓存替身初始化完整音乐识别缓存实例。""" monkeypatch.setattr(music_cache_module, "time", lambda: now) monkeypatch.setattr(music_cache_module, "TTLCache", lambda **kwargs: runtime_cache) monkeypatch.setattr(music_cache_module, "FileCache", lambda **kwargs: file_cache) cache = object.__new__(MusicBrainzCache) cache.__init__() return cache def _music_info(**kwargs) -> MusicInfo: """构造标准音乐识别结果,默认携带远端身份。""" defaults = { "source": "musicbrainz", "media_id": "rec-1", "title": "晴天", "artists": ["周杰伦"], "album": "叶惠美", "year": 2003, } defaults.update(kwargs) return MusicInfo(**defaults) def test_music_cache_endpoints_require_superuser(): """音乐识别缓存管理接口必须仅允许超级管理员访问。""" endpoints = [ music_endpoint.music_recognition_cache, music_endpoint.delete_music_recognition_cache, music_endpoint.clear_music_recognition_cache, ] for endpoint in endpoints: dependency = inspect.signature(endpoint).parameters["_"].default.dependency assert dependency is get_current_active_superuser_async def test_music_cache_key_prefers_media_id(): """携带数据源原生 ID 的元数据应以 ID 作为缓存键主身份。""" cache = _build_music_cache({}) meta = MetaMusic(title="晴天", artists=["周杰伦"], media_id="rec-1") cache.update(meta, _music_info()) # 缓存键取自请求元数据,携带原生 ID 时以 ID 为主身份 assert list(cache._cache.data.keys()) == ["[音乐]rec-1-周杰伦-None-None"] def test_music_cache_update_and_get_roundtrip(): """识别结果入缓存后应能还原出标准音乐信息,且不保存上游原始响应。""" cache = _build_music_cache({}) meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) info = _music_info(raw_data={"payload": "large"}) cache.update(meta, info) hit = cache.get(meta) assert hit is not None assert hit.media_id == "rec-1" assert hit.title == "晴天" assert hit.artists == ["周杰伦"] stored = next(iter(cache._cache.data.values())) assert "raw_data" not in stored def test_music_cache_get_miss_returns_none(): """未命中的缓存查询应返回 None 而不是抛错。""" cache = _build_music_cache({}) meta = MetaMusic(title="未知曲目") assert cache.get(meta) is None def test_music_cache_list_items_normalizes_and_sorts(): """管理列表应输出稳定顺序和前端展示所需字段。""" cache = _build_music_cache({ "z-key": { "media_id": "rec-2", "title": "Zulu", "artists": ["歌手B"], "album": "专辑B", "year": 2024, "music_type": "recording", }, "a-key": { "media_id": "", "title": "Alpha", "year": 2023, }, }) items = cache.list_items() assert [item["key"] for item in items] == ["a-key", "z-key"] assert items[0]["media_id"] == "" assert items[0]["artists"] == [] assert items[0]["music_type"] == "recording" assert items[1]["artists"] == ["歌手B"] def test_music_cache_delete_and_clear_persist_immediately(monkeypatch): """管理操作应修改运行时缓存并立即触发本地持久化。""" cache = _build_music_cache({"first": {"media_id": "rec-1"}, "second": {"media_id": "rec-2"}}) saved_forces = [] monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force)) assert cache.delete("first") == {"media_id": "rec-1"} assert cache.delete("missing") == {} cache.clear() assert cache.list_items() == [] assert saved_forces == [True, True] def test_music_cache_restores_only_unexpired_persisted_items(monkeypatch): """持久化恢复应保留每条数据原有期限并跳过已过期条目。""" payload = { "version": music_cache_module.PERSISTENCE_VERSION, "items": { "fresh": { "value": {"media_id": "rec-1", "title": "有效"}, "expires_at": 1030, }, "expired": { "value": {"media_id": "rec-2", "title": "过期"}, "expires_at": 999, }, }, } file_cache = _FileCacheStub(pickle.dumps(payload)) runtime_cache = _TTLCacheStub() cache = _build_initialized_music_cache( monkeypatch=monkeypatch, file_cache=file_cache, runtime_cache=runtime_cache, ) assert runtime_cache.data == {"fresh": {"media_id": "rec-1", "title": "有效"}} assert runtime_cache.ttls == {"fresh": 30} assert cache._expires_at == {"fresh": 1030} def test_music_cache_persists_only_items_with_media_id(monkeypatch): """持久化应跳过未识别的负缓存条目,只保存携带远端身份的结果。""" file_cache = _FileCacheStub() runtime_cache = _TTLCacheStub() cache = _build_initialized_music_cache( monkeypatch=monkeypatch, file_cache=file_cache, runtime_cache=runtime_cache, ) runtime_cache.data = { "recognized": {"media_id": "rec-1", "title": "晴天"}, "negative": {"media_id": "", "title": "未知曲目"}, } cache._expires_at = { "recognized": 1060, "negative": 1070, } cache._dirty = True cache.save() payload = pickle.loads(file_cache.content) assert file_cache.set_calls == [( music_cache_module.PERSISTENCE_KEY, music_cache_module.PERSISTENCE_REGION, )] assert payload == { "version": music_cache_module.PERSISTENCE_VERSION, "items": { "recognized": { "value": {"media_id": "rec-1", "title": "晴天"}, "expires_at": 1060, }, }, } def test_music_cache_save_removes_file_when_empty(monkeypatch): """全部条目失效后保存应删除持久化文件。""" file_cache = _FileCacheStub(pickle.dumps({"version": 1, "items": {}})) runtime_cache = _TTLCacheStub() cache = _build_initialized_music_cache( monkeypatch=monkeypatch, file_cache=file_cache, runtime_cache=runtime_cache, ) cache._dirty = True cache.save() assert file_cache.delete_calls == [( music_cache_module.PERSISTENCE_KEY, music_cache_module.PERSISTENCE_REGION, )] def test_music_cache_endpoint_returns_management_statistics(monkeypatch): """查询接口应返回识别成功和失败条目的统计。""" cache = _build_music_cache({ "recognized": {"media_id": "rec-1", "title": "晴天"}, "unrecognized": {"media_id": "", "title": "未知曲目"}, }) monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache) response = asyncio.run(music_endpoint.music_recognition_cache(None)) assert response.success is True assert response.data["count"] == 2 assert response.data["recognized"] == 1 assert response.data["unrecognized"] == 1 assert len(response.data["data"]) == 2 def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch): """删除接口应区分成功删除与缓存不存在。""" cache = _build_music_cache({"existing": {"media_id": "rec-1"}}) monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache) deleted_response = asyncio.run( music_endpoint.delete_music_recognition_cache("existing", None) ) missing_response = asyncio.run( music_endpoint.delete_music_recognition_cache("missing", None) ) assert deleted_response.success is True assert missing_response.success is False def test_music_cache_clear_endpoint_removes_all_items(monkeypatch): """清空接口应删除全部音乐识别缓存。""" cache = _build_music_cache({"existing": {"media_id": "rec-1"}}) monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache) response = asyncio.run(music_endpoint.clear_music_recognition_cache(None)) assert response.success is True assert cache.list_items() == [] def _build_module_with_cache(cache: MusicBrainzCache) -> MusicBrainzModule: """构造挂载测试缓存的 MusicBrainz 模块实例。""" module = MusicBrainzModule() module.cache = cache return module def test_module_recognize_media_hits_cache_without_search(monkeypatch): """识别缓存命中时应直接返回缓存结果,不再触发 MusicBrainz 搜索。""" cache = _build_music_cache({}) module = _build_module_with_cache(cache) meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) cache.update(meta, _music_info()) search_mock = Mock() monkeypatch.setattr(module, "_search_recordings", search_mock) result = module.recognize_media(meta=meta) assert result is not None assert result.media_id == "rec-1" assert getattr(result, "recognize_cache_hit") is True search_mock.assert_not_called() def test_module_recognize_media_bypasses_cache_when_disabled(monkeypatch): """cache=False 时不读取缓存,重新走搜索识别流程。""" cache = _build_music_cache({}) module = _build_module_with_cache(cache) meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) cache.update(meta, _music_info()) fresh = _music_info(media_id="rec-2") monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[fresh])) monkeypatch.setattr(module, "_select_candidate", Mock(return_value=fresh)) result = module.recognize_media(meta=meta, cache=False) assert result is fresh assert getattr(result, "recognize_cache_hit", False) is False def test_module_recognize_media_writes_search_result_to_cache(monkeypatch): """搜索识别成功后应回填本地识别缓存。""" cache = _build_music_cache({}) module = _build_module_with_cache(cache) meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) matched = _music_info() monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[matched])) monkeypatch.setattr(module, "_select_candidate", Mock(return_value=matched)) result = module.recognize_media(meta=meta) assert result is matched hit = cache.get(meta) assert hit is not None assert hit.media_id == "rec-1" def test_module_recognize_media_caches_offline_fallback(monkeypatch): """搜索无结果的兜底信息也应进入负缓存,避免重复请求。""" cache = _build_music_cache({}) module = _build_module_with_cache(cache) meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[])) monkeypatch.setattr(module, "_search_albums", Mock(return_value=[])) result = module.recognize_media(meta=meta) assert result is not None assert result.media_id is None cached = cache.get(meta) assert cached is not None assert cached.media_id is None assert cached.title == "未知曲目" def test_module_update_recognize_cache_only_for_musicbrainz_music(): """共享识别回填仅处理本数据源的音乐结果。""" cache = _build_music_cache({}) module = _build_module_with_cache(cache) meta = MetaMusic(title="晴天", artists=["周杰伦"]) assert module.update_recognize_cache(meta=meta, mediainfo=_music_info()) is True assert cache.get(meta) is not None other_source = _music_info(source="other", media_id="x-1") assert module.update_recognize_cache(meta=meta, mediainfo=other_source) is None assert module.update_recognize_cache(meta=None, mediainfo=_music_info()) is None