import json import os import tempfile import threading import unittest from unittest.mock import patch import mcp_server class _CacheIsolationMixin: """所有测试共享:隔离 module-level 缓存状态 + 指向 tempdir 的 cache 文件。""" def setUp(self): self._saved_cache = mcp_server._voice_transcription_cache self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE self._saved_warned = mcp_server._voice_transcription_save_warned mcp_server._voice_transcription_cache = None mcp_server._voice_transcription_save_warned = False self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join( self._tmp.name, "voice_transcriptions.json" ) def tearDown(self): mcp_server._voice_transcription_cache = self._saved_cache mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path mcp_server._voice_transcription_save_warned = self._saved_warned class VoiceTranscriptionCachePersistenceTests(_CacheIsolationMixin, unittest.TestCase): """_load_voice_transcription_cache / _save_voice_transcription_cache 的持久化行为。""" def test_load_missing_file_returns_empty_dict(self): self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) def test_save_and_reload_roundtrip(self): cache = mcp_server._load_voice_transcription_cache() cache["wxid_foo:42"] = { "text": "你好", "language": "zh", "create_time": 1700000000, "model_size": "base", } mcp_server._save_voice_transcription_cache() # 强制下一次 load 从磁盘读 mcp_server._voice_transcription_cache = None reloaded = mcp_server._load_voice_transcription_cache() self.assertEqual(reloaded["wxid_foo:42"]["text"], "你好") self.assertEqual(reloaded["wxid_foo:42"]["language"], "zh") def test_corrupt_file_returns_empty_dict(self): with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f: f.write("{{ not valid json") self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) def test_non_dict_payload_returns_empty_dict(self): with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f: json.dump(["not", "a", "dict"], f) self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) def test_utf8_preserved_on_disk(self): # ensure_ascii=False 必须生效,否则中文会被转义成 \uXXXX cache = mcp_server._load_voice_transcription_cache() cache["wxid_bar:1"] = {"text": "中文测试", "language": "zh"} mcp_server._save_voice_transcription_cache() with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "rb") as f: raw = f.read() self.assertIn("中文测试".encode("utf-8"), raw) def test_save_without_prior_load_persists_empty_dict(self): # 从未 load 过就直接 save:应落盘一个空 dict,而不是静默丢弃。 mcp_server._voice_transcription_cache = None mcp_server._save_voice_transcription_cache() self.assertTrue(os.path.exists(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE)) with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: self.assertEqual(json.load(f), {}) class VoiceTranscriptionCacheAtomicityTests(_CacheIsolationMixin, unittest.TestCase): """原子写 + crash-during-save 行为。""" def test_write_is_atomic_via_rename(self): # 先写入一份已有缓存 cache = mcp_server._load_voice_transcription_cache() cache["wxid_x:1"] = {"text": "initial", "language": "zh", "model_size": "base"} mcp_server._save_voice_transcription_cache() # 模拟:写 .tmp 正常但 os.replace 阶段失败 original_replace = os.replace def flaky_replace(src, dst): raise OSError("disk full during rename") cache["wxid_x:1"] = {"text": "MUTATED", "language": "zh", "model_size": "base"} with patch.object(os, "replace", side_effect=flaky_replace): mcp_server._save_voice_transcription_cache() # 不应抛 # 磁盘上应仍然是 initial,不是 MUTATED,也不是损坏的半截文件 with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: disk = json.load(f) self.assertEqual(disk["wxid_x:1"]["text"], "initial") # .tmp 应该被清理,避免污染目录 tmp_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE + ".tmp" # 注:patch 生效期间 os.replace 失败,finally 里会尝试 unlink _ = original_replace # 防 lint 警告 self.assertFalse(os.path.exists(tmp_path)) def test_early_save_error_preserves_existing_file(self): # json.dump 在 .tmp 上抛异常时(模拟磁盘满 / 权限问题),主文件应保持原样; # 注意此测试不是"写到一半中断"而是"写前就失败"的场景。 cache = mcp_server._load_voice_transcription_cache() cache["wxid_y:1"] = {"text": "survives", "language": "zh", "model_size": "base"} mcp_server._save_voice_transcription_cache() cache["wxid_y:1"] = {"text": "DO NOT SEE", "language": "zh", "model_size": "base"} def boom(*args, **kwargs): raise OSError("disk full") with patch.object(mcp_server.json, "dump", side_effect=boom): mcp_server._save_voice_transcription_cache() # 静默降级,不抛 # 主文件没被破坏:仍然可 json.load 出原先内容 mcp_server._voice_transcription_cache = None reloaded = mcp_server._load_voice_transcription_cache() self.assertEqual(reloaded["wxid_y:1"]["text"], "survives") class VoiceTranscriptionCacheConcurrencyTests(_CacheIsolationMixin, unittest.TestCase): """多线程下的 load/save 行为。""" def test_concurrent_load_returns_same_dict_instance(self): # 16 个线程同时触发首次 load,应当只实际化一份 dict(lock 生效) barrier = threading.Barrier(16) results = [] results_lock = threading.Lock() def worker(): barrier.wait() d = mcp_server._load_voice_transcription_cache() with results_lock: results.append(id(d)) threads = [threading.Thread(target=worker) for _ in range(16)] for t in threads: t.start() for t in threads: t.join() self.assertEqual(len(set(results)), 1, "并发 load 应返回同一个 dict 对象") def test_concurrent_save_does_not_corrupt(self): # 多个线程同时 save,磁盘上最终文件必须是合法 JSON(原子写 + lock 保障) cache = mcp_server._load_voice_transcription_cache() for i in range(100): cache[f"wxid_z:{i}"] = { "text": f"msg-{i}", "language": "zh", "model_size": "base", } def worker(): mcp_server._save_voice_transcription_cache() threads = [threading.Thread(target=worker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: disk = json.load(f) # 必须能解析 self.assertEqual(len(disk), 100) class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase): """transcribe_voice 的缓存命中 / 失效路径。""" def _seed(self, key, entry): cache = mcp_server._load_voice_transcription_cache() cache[key] = entry mcp_server._save_voice_transcription_cache() def test_cache_hit_skips_fetch_and_transcribe(self): key = mcp_server._voice_transcription_cache_key("wxid_test", 7) self._seed(key, { "text": "缓存命中文本", "language": "zh", "create_time": 1700000000, "model_size": mcp_server.LOCAL_WHISPER_MODEL, }) with patch.object(mcp_server, "resolve_username", return_value="wxid_test") as mock_resolve, \ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch, \ patch.object(mcp_server, "_silk_to_wav") as mock_silk, \ patch.object(mcp_server, "_get_whisper_model") as mock_model: result = mcp_server.transcribe_voice("test_contact", 7) mock_resolve.assert_called_once_with("test_contact") mock_fetch.assert_not_called() mock_silk.assert_not_called() mock_model.assert_not_called() self.assertIn("缓存命中文本", result) self.assertIn("(zh)", result) def test_cache_hit_uses_placeholder_when_create_time_missing(self): # 旧条目若没有 create_time 字段,不应崩溃 key = mcp_server._voice_transcription_cache_key("wxid_test", 8) self._seed(key, { "text": "历史条目", "language": "zh", "model_size": mcp_server.LOCAL_WHISPER_MODEL, }) with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch: result = mcp_server.transcribe_voice("test_contact", 8) mock_fetch.assert_not_called() self.assertIn("历史条目", result) def test_cache_hit_returns_empty_text_without_retranscribing(self): # Whisper 返回空也要缓存;再次调用应直接返回空,不进入 miss 路径 key = mcp_server._voice_transcription_cache_key("wxid_test", 9) self._seed(key, { "text": "", "language": "zh", "create_time": 1700000000, "model_size": mcp_server.LOCAL_WHISPER_MODEL, }) with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch: result = mcp_server.transcribe_voice("test_contact", 9) mock_fetch.assert_not_called() self.assertIn("(zh)", result) def test_model_mismatch_is_treated_as_miss(self): # 缓存条目的 model_size 和当前 LOCAL_WHISPER_MODEL 不一致时, # 不应命中;进入 miss 路径(这里无 whisper 依赖,应落到"缺少依赖"分支)。 key = mcp_server._voice_transcription_cache_key("wxid_test", 10) self._seed(key, { "text": "旧模型结果", "language": "zh", "create_time": 1700000000, "model_size": "OUTDATED_MODEL", }) with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ patch.dict("sys.modules", {"whisper": None}): # whisper=None 时 `import whisper` 触发 ImportError result = mcp_server.transcribe_voice("test_contact", 10) # 走了 miss 路径 → 返回缺依赖提示,而不是返回旧缓存文本 self.assertNotIn("旧模型结果", result) self.assertIn("缺少依赖", result) def test_cache_key_handles_colon_in_username(self): # 若上游未来的 resolve_username 放出带 ':' 的 username,也不会和其他条目冲突 key_a = mcp_server._voice_transcription_cache_key("wxid:foo", 1) key_b = mcp_server._voice_transcription_cache_key("wxid", 1) self.assertNotEqual(key_a, key_b) if __name__ == "__main__": unittest.main()