"""报错文案映射服务测试。 覆盖三块: 1. 关键词归一化与规则归一化(脏数据要能安全丢弃 / 去重); 2. translate 的命中优先级(命中即替换、未命中记待办并保留原文); 3. 未配置报错的收集、去重计数、清空与上限淘汰。 """ import json import tempfile import unittest from pathlib import Path class KeywordNormalizationTests(unittest.TestCase): def test_accepts_list_and_separator_strings(self) -> None: from services.error_message_service import _normalize_keywords self.assertEqual(_normalize_keywords(["a1", "b2"]), ["a1", "b2"]) # 换行 / 逗号 / 中文逗号 / 顿号都应被拆开 self.assertEqual(_normalize_keywords("a1\nb2,c3,d4、e5"), ["a1", "b2", "c3", "d4", "e5"]) def test_drops_short_and_duplicate_keywords(self) -> None: from services.error_message_service import _normalize_keywords # 短于 MIN_KEYWORD_CHARS 的关键词会误伤正常报错,必须丢掉;重复项按小写去重 self.assertEqual(_normalize_keywords(["a", "ab", "AB", "cd", ""]), ["ab", "cd"]) def test_ignores_non_string_input(self) -> None: from services.error_message_service import _normalize_keywords self.assertEqual(_normalize_keywords(None), []) self.assertEqual(_normalize_keywords(123), []) class RuleNormalizationTests(unittest.TestCase): def test_drops_rules_missing_keywords_or_display(self) -> None: from services.error_message_service import normalize_rules rules = normalize_rules([ {"keywords": ["throttled"], "display": "太挤了"}, {"keywords": [], "display": "没关键词"}, {"keywords": ["x1"], "display": ""}, "not-a-dict", ]) self.assertEqual(len(rules), 1) self.assertEqual(rules[0]["keywords"], ["throttled"]) def test_enabled_defaults_and_string_coercion(self) -> None: from services.error_message_service import normalize_rules rules = normalize_rules([ {"id": "r1", "keywords": ["aa"], "display": "x"}, {"id": "r2", "keywords": ["bb"], "display": "y", "enabled": "false"}, {"id": "r3", "keywords": ["cc"], "display": "z", "enabled": "yes"}, ]) self.assertTrue(rules[0]["enabled"]) self.assertFalse(rules[1]["enabled"]) self.assertTrue(rules[2]["enabled"]) def test_duplicate_ids_get_suffixed(self) -> None: from services.error_message_service import normalize_rules rules = normalize_rules([ {"id": "dup", "keywords": ["aa"], "display": "x"}, {"id": "dup", "keywords": ["bb"], "display": "y"}, ]) self.assertEqual([rule["id"] for rule in rules], ["dup", "dup-2"]) def test_missing_id_gets_fallback(self) -> None: from services.error_message_service import normalize_rules rules = normalize_rules([{"keywords": ["aa"], "display": "x"}]) self.assertEqual(rules[0]["id"], "rule-1") class TranslateTests(unittest.TestCase): """translate 的命中 / 未命中行为。用临时文件隔离未配置报错的写入。""" def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self._store = Path(self._tmp.name) / "unconfigured.json" def tearDown(self) -> None: self._tmp.cleanup() def _service(self, rules): from services.error_message_service import ErrorMessageService service = ErrorMessageService(store_path=self._store) service._rules = lambda: __import__( "services.error_message_service", fromlist=["normalize_rules"] ).normalize_rules(rules) return service def test_hit_returns_display_text(self) -> None: service = self._service([{"keywords": ["upload_protection_limit_exceeded"], "display": "上传额度已满"}]) raw = '/backend-api/files failed: status=429, body={"reason": "upload_protection_limit_exceeded"}' self.assertEqual(service.translate(raw), "上传额度已满") def test_match_is_case_insensitive(self) -> None: service = self._service([{"keywords": ["Throttled"], "display": "太挤了"}]) self.assertEqual(service.translate("upstream THROTTLED now"), "太挤了") def test_disabled_rule_is_skipped(self) -> None: service = self._service([{"keywords": ["throttled"], "display": "太挤了", "enabled": False}]) self.assertEqual(service.translate("throttled"), "throttled") def test_miss_keeps_original_and_records_todo(self) -> None: service = self._service([{"keywords": ["throttled"], "display": "太挤了"}]) raw = "upstream connect failed: 由于目标计算机积极拒绝" self.assertEqual(service.translate(raw, channel="api"), raw) items = service.list_unconfigured() self.assertEqual(len(items), 1) self.assertEqual(items[0]["channel"], "api") self.assertEqual(items[0]["count"], 1) def test_empty_message_is_ignored(self) -> None: service = self._service([]) self.assertEqual(service.translate(""), "") self.assertEqual(service.translate(None), "") self.assertEqual(service.list_unconfigured(), []) def test_translate_detail_only_touches_text_fields(self) -> None: service = self._service([{"keywords": ["throttled"], "display": "太挤了"}]) detail = {"code": "throttled", "message": "throttled for you", "type": "throttled"} result = service.translate_detail(detail) self.assertEqual(result["message"], "太挤了") # code / type 是程序判断用的,不能被替换 self.assertEqual(result["code"], "throttled") self.assertEqual(result["type"], "throttled") class FallbackDisplayTests(unittest.TestCase): """未命中规则时的「兜底成通用文案」开关(后台可配,默认关)。 后台那个勾选框写着「勾选后未命中规则的报错前台统一显示『操作失败,请稍后重试』」, 所以这里把开关的两个状态都钉住:关 = 原样显示(便于补规则),开 = 通用文案。 """ def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self._store = Path(self._tmp.name) / "unconfigured.json" def tearDown(self) -> None: self._tmp.cleanup() def _service(self, rules, *, fallback: bool): from services.error_message_service import ErrorMessageService, normalize_rules service = ErrorMessageService(store_path=self._store) service._rules = lambda: normalize_rules(rules) # 用实例属性覆盖静态方法,避免依赖真实配置(测试进程里 config 的共享设置不可控)。 service.fallback_enabled = lambda: fallback return service def test_unmatched_keeps_raw_text_when_disabled(self) -> None: service = self._service([], fallback=False) raw = "upstream connect failed: 由于目标计算机积极拒绝" self.assertEqual(service.translate(raw, channel="image_task"), raw) def test_unmatched_uses_generic_copy_when_enabled(self) -> None: from services.error_message_service import FALLBACK_DISPLAY service = self._service([], fallback=True) self.assertEqual(service.translate("upstream boom"), FALLBACK_DISPLAY) # ⚠️ 兜底只换前台文案:原文照样进「未配置报错」台账,管理员还能照着补规则。 items = service.list_unconfigured() self.assertEqual(len(items), 1) self.assertIn("upstream boom", items[0]["sample"]) def test_matched_rule_wins_over_fallback(self) -> None: service = self._service([{"keywords": ["throttled"], "display": "太挤了"}], fallback=True) self.assertEqual(service.translate("upstream THROTTLED now"), "太挤了") def test_disabled_rule_under_fallback_falls_back(self) -> None: """停用的规则不算命中:开着兜底时不能因为它而漏出技术报错。""" from services.error_message_service import FALLBACK_DISPLAY service = self._service( [{"keywords": ["throttled"], "display": "太挤了", "enabled": False}], fallback=True, ) self.assertEqual(service.translate("throttled"), FALLBACK_DISPLAY) def test_empty_message_stays_empty(self) -> None: service = self._service([], fallback=True) self.assertEqual(service.translate(""), "") self.assertEqual(service.translate(None), "") def test_flag_reads_from_config(self) -> None: from unittest.mock import patch from services.config import config from services.error_message_service import ErrorMessageService with patch.object(type(config), "error_message_fallback_enabled", property(lambda self: True)): self.assertTrue(ErrorMessageService.fallback_enabled()) with patch.object(type(config), "error_message_fallback_enabled", property(lambda self: False)): self.assertFalse(ErrorMessageService.fallback_enabled()) class UnconfiguredCollectionTests(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self._store = Path(self._tmp.name) / "unconfigured.json" def tearDown(self) -> None: self._tmp.cleanup() def _service(self): from services.error_message_service import ErrorMessageService service = ErrorMessageService(store_path=self._store) service._rules = lambda: [] return service def test_same_shape_errors_share_one_fingerprint(self) -> None: """同一类报错每次 uuid / 数字不同,不应各记一条。""" service = self._service() service.translate("/api/x failed id=550e8400-e29b-41d4-a716-446655440000 code=429") service.translate("/api/x failed id=6ba7b810-9dad-11d1-80b4-00c04fd430c8 code=500") items = service.list_unconfigured() self.assertEqual(len(items), 1) self.assertEqual(items[0]["count"], 2) def test_list_sorted_by_count_desc(self) -> None: service = self._service() service.translate("alpha error occurred") service.translate("beta error occurred") service.translate("beta error occurred") service.translate("beta error occurred") items = service.list_unconfigured() self.assertEqual(items[0]["count"], 3) self.assertEqual(items[1]["count"], 1) def test_list_includes_keyword_candidates(self) -> None: """待办列表要带上可直接用的候选关键词,省得管理员手抠。""" service = self._service() service.translate('beta failed: status=429, body={"reason": "some_specific_reason"}') items = service.list_unconfigured() self.assertEqual(len(items), 1) self.assertIn("some_specific_reason", items[0]["keywords"]) self.assertIn("status=429", items[0]["keywords"]) def test_persists_across_instances(self) -> None: from services.error_message_service import ErrorMessageService service = self._service() service.translate("some unique error text") reloaded = ErrorMessageService(store_path=self._store) reloaded._rules = lambda: [] self.assertEqual(len(reloaded.list_unconfigured()), 1) def test_clear_selected_and_all(self) -> None: service = self._service() service.translate("alpha error occurred") service.translate("beta error occurred") items = service.list_unconfigured() target = items[0]["fingerprint"] self.assertEqual(service.clear_unconfigured([target]), 1) self.assertEqual(len(service.list_unconfigured()), 1) self.assertEqual(service.clear_unconfigured(), 1) self.assertEqual(service.list_unconfigured(), []) def test_entry_cap_prunes_oldest(self) -> None: from services import error_message_service as module service = self._service() original_cap = module._MAX_UNCONFIGURED_ENTRIES module._MAX_UNCONFIGURED_ENTRIES = 10 try: for index in range(30): service.translate(f"unique error number {index:04d} text") self.assertLessEqual(len(service.list_unconfigured()), 10) finally: module._MAX_UNCONFIGURED_ENTRIES = original_cap def test_corrupt_store_is_tolerated(self) -> None: self._store.write_text("{ this is not json", encoding="utf-8") service = self._service() # 坏文件不能让请求链路崩,且能继续写入 service.translate("brand new error text") self.assertEqual(len(service.list_unconfigured()), 1) def test_sample_is_truncated(self) -> None: from services import error_message_service as module service = self._service() service.translate("x" * (module._MAX_SAMPLE_CHARS + 500)) items = service.list_unconfigured() self.assertEqual(len(items[0]["sample"]), module._MAX_SAMPLE_CHARS) class KeywordCandidateExtractionTests(unittest.TestCase): """本地抽取:AI 不可用时的兜底,必须能捞出高辨识度片段。""" def test_extracts_from_real_upload_throttle_payload(self) -> None: from services.error_message_service import extract_keyword_candidates raw = ( '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", ' '"error_code": "throttled", "message": "你已达到文件上传上限。请4小时 内重试。", ' '"type": "throttled", "reason": "upload_protection_limit_exceeded"}}' ) candidates = extract_keyword_candidates(raw) # 最精确的标识符排在最前 self.assertEqual(candidates[0], "upload_protection_limit_exceeded") self.assertIn("/backend-api/files", candidates) self.assertIn("status=429", candidates) def test_skips_json_key_names_and_generic_words(self) -> None: from services.error_message_service import extract_keyword_candidates raw = '{"error_code": "abc12345", "message": "failed", "reason": "xyz98765"}' candidates = extract_keyword_candidates(raw) # 字段名本身不参与匹配,否则等于没筛 for noise in ("error_code", "message", "reason", "failed", "status"): self.assertNotIn(noise, candidates) def test_extracts_curl_noise(self) -> None: from services.error_message_service import extract_keyword_candidates self.assertIn("curl: (16)", extract_keyword_candidates("upstream curl: (16) recv failure")) def test_deduplicates_and_handles_empty(self) -> None: from services.error_message_service import extract_keyword_candidates self.assertEqual(extract_keyword_candidates(""), []) self.assertEqual(extract_keyword_candidates(None), []) repeated = extract_keyword_candidates("status=429 then again status=429") self.assertEqual(repeated.count("status=429"), 1) def test_no_json_syntax_fragments_in_candidates(self) -> None: """候选词里不能出现引号/冒号/花括号 —— 那是报文语法,不是错误标识。""" from services.error_message_service import extract_keyword_candidates raw = ( '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", ' '"reason": "upload_protection_limit_exceeded"}}' ) candidates = extract_keyword_candidates(raw) # 注意不能一律禁冒号:`curl: (16)` 这种是合法关键词,只禁 JSON 的 `": "` 片段。 for candidate in candidates: for noise in ('"', "'", "{", "}", '": "'): self.assertNotIn(noise, candidate, msg=f"{candidate!r} 里混进了 JSON 语法碎片") class ReportedUploadErrorTests(unittest.TestCase): """用户实际上报的 /uploaded 500 报错:从「粘贴原文」到「规则真能命中」必须闭环。 这条报错是多模态里最常见的 500(file_id 每次不同),用来守住: 1. 本地抽取能捞出高辨识度片段; 2. 模型编造的关键词会被丢弃; 3. 归纳出来的规则确实能命中原始报错(子串匹配)。 """ RAW = ( "/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded failed: status=500, " 'body={"detail": {"code": "internal_error", "error_code": "internal_error"}}' ) def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self._store = Path(self._tmp.name) / "unconfigured.json" def tearDown(self) -> None: self._tmp.cleanup() def test_extracts_usable_candidates(self) -> None: from services.error_message_service import extract_keyword_candidates candidates = extract_keyword_candidates(self.RAW) self.assertIn("/backend-api/files", candidates) self.assertIn("status=500", candidates) self.assertIn("internal_error", candidates) def test_ai_suggestion_drops_hallucinated_keywords(self) -> None: from services.error_message_service import parse_ai_suggestion answer = ( '{"keywords": ["status=500", "internal_error", "definitely_absent", "error_code"], ' '"display": "文件上传失败,请稍后重试"}' ) result = parse_ai_suggestion(answer, raw=self.RAW) self.assertEqual(result["display"], "文件上传失败,请稍后重试") self.assertIn("status=500", result["keywords"]) self.assertIn("internal_error", result["keywords"]) # 原文里没有的词永远命中不了;JSON 字段名会命中所有报错,两者都要剔。 self.assertNotIn("definitely_absent", result["keywords"]) self.assertNotIn("error_code", result["keywords"]) def test_volatile_path_segments_are_trimmed(self) -> None: from services.error_message_service import _strip_volatile_path # file_id 每次都变,关键词必须停在它之前,否则规则只对这一次生效。 self.assertEqual( _strip_volatile_path("/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded"), "/backend-api/files", ) self.assertEqual(_strip_volatile_path("/backend-api/files"), "/backend-api/files") # 单段路径太泛,直接丢弃。 self.assertEqual(_strip_volatile_path("/files"), "") def test_generated_rule_matches_the_reported_error(self) -> None: from services.error_message_service import ErrorMessageService, extract_keyword_candidates service = ErrorMessageService(store_path=self._store) service._rules = lambda: __import__( "services.error_message_service", fromlist=["normalize_rules"] ).normalize_rules( [ { "keywords": extract_keyword_candidates(self.RAW), "display": "文件上传失败,请稍后重试", } ] ) # file_id 每次都不同,命中靠的是路径前缀 + 状态码,不是整条原文。 other = ( "/backend-api/files/file_00000000aaaaaaaaaaaaaaaaaaaaaaaa/uploaded failed: status=500, " 'body={"detail": {"code": "internal_error"}}' ) self.assertEqual(service.translate(self.RAW), "文件上传失败,请稍后重试") self.assertEqual(service.translate(other), "文件上传失败,请稍后重试") # 429 上传限流:和上面那条 500 报错共用 /backend-api/files 前缀。 THROTTLE = ( '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", ' '"error_code": "throttled", "reason": "upload_protection_limit_exceeded"}}' ) def _service_with(self, rules): from services.error_message_service import ErrorMessageService service = ErrorMessageService(store_path=self._store) service._rules = lambda: __import__( "services.error_message_service", fromlist=["normalize_rules"] ).normalize_rules(rules) return service def test_upload_500_rule_does_not_swallow_throttle_error(self) -> None: """两条规则必须正交:500 规则不能吞掉 429 限流,反之亦然。 匹配是「任一关键词命中」,所以关键词选宽一点就会让两条规则互相覆盖。 这是「结合本项目」的核心:路径必须带上区分步骤的片段。 """ service = self._service_with( [ {"keywords": ["internal_error", "/uploaded failed: status=500"], "display": "文件上传失败,请重试"}, {"keywords": ["upload_protection_limit_exceeded", "你已达到文件上传上限"], "display": "文件上传次数已达上限"}, ] ) self.assertEqual(service.translate(self.RAW), "文件上传失败,请重试") self.assertEqual(service.translate(self.THROTTLE), "文件上传次数已达上限") def test_broad_path_prefix_swallows_other_errors(self) -> None: """反面用例:只有裸前缀 /backend-api/files 时,500 规则会吞掉 429 限流报错。 这条用例存在的意义是防止有人把提示词改回「只写接口路径前缀」—— 那样前台会把「配额用满」误报成「服务内部错误」,用户会一直重试而不是等换号。 """ service = self._service_with( [{"keywords": ["/backend-api/files"], "display": "服务器内部错误,请稍后重试"}] ) self.assertEqual(service.translate(self.RAW), "服务器内部错误,请稍后重试") # 这就是误伤:限流报错也被换成了 500 的文案。 self.assertEqual(service.translate(self.THROTTLE), "服务器内部错误,请稍后重试") class AiSuggestionParsingTests(unittest.TestCase): def test_parses_plain_json(self) -> None: from services.error_message_service import parse_ai_suggestion result = parse_ai_suggestion('{"keywords": ["aa", "bb"], "display": "出错了"}') self.assertEqual(result["keywords"], ["aa", "bb"]) self.assertEqual(result["display"], "出错了") def test_strips_markdown_code_fence(self) -> None: from services.error_message_service import parse_ai_suggestion result = parse_ai_suggestion('```json\n{"keywords": ["aa"], "display": "出错了"}\n```') self.assertEqual(result["keywords"], ["aa"]) def test_tolerates_surrounding_prose(self) -> None: from services.error_message_service import parse_ai_suggestion result = parse_ai_suggestion('好的,结果如下:\n{"keywords": ["aa"], "display": "出错了"}\n希望有帮助') self.assertEqual(result["display"], "出错了") def test_drops_keywords_absent_from_raw(self) -> None: """关键在原文里必须真实出现,否则规则永远命中不了。""" from services.error_message_service import parse_ai_suggestion result = parse_ai_suggestion( '{"keywords": ["upload_protection_limit_exceeded", "hallucinated_token"], "display": "太挤了"}', raw="/backend-api/files failed: upload_protection_limit_exceeded", ) self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded"]) def test_bad_output_returns_empty(self) -> None: from services.error_message_service import parse_ai_suggestion for bad in ("", None, "抱歉我无法处理", "[1,2,3]", "{ not json"): self.assertEqual(parse_ai_suggestion(bad, raw="x")["keywords"], []) self.assertEqual(parse_ai_suggestion(bad, raw="x")["display"], "") def test_prompt_forbids_generic_keywords(self) -> None: from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT for word in ("error", "failed", "exception"): self.assertIn(word, ERROR_SUMMARY_SYSTEM_PROMPT) def test_sanitizes_json_fragment_keywords(self) -> None: """模型会把 `reason": "upload_xxx` 这种截断片段整条当关键词。 它虽然能把当前这条报错匹配上,但只要上游 JSON 的字段顺序或空格一变 就失效;归纳链路必须只保留「值」本身。 """ from services.error_message_service import parse_ai_suggestion raw = '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", "reason": "upload_protection_limit_exceeded"}}' result = parse_ai_suggestion( json.dumps({ "keywords": ['reason": "upload_protection_limit_exceeded', "status=429"], "display": "上传频率过高,请稍后再试", }), raw=raw, ) self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded", "status=429"]) def test_prompt_carries_project_context(self) -> None: """归纳要「结合本项目」:必须告诉模型匹配语义、报文格式和路径步骤要求。 少了这些,模型会把 /backend-api/files 这种通用前缀当关键词; 而匹配是「任一命中」,一条规则就会吞掉一大片语义完全不同的报错。 """ from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT self.assertIn("任意一个", ERROR_SUMMARY_SYSTEM_PROMPT) self.assertIn("failed: status=", ERROR_SUMMARY_SYSTEM_PROMPT) for step in ("/uploaded", "/download", "/library"): self.assertIn(step, ERROR_SUMMARY_SYSTEM_PROMPT) self.assertIn("file_id", ERROR_SUMMARY_SYSTEM_PROMPT) class ConfigIntegrationTests(unittest.TestCase): def test_config_exposes_rule_helpers(self) -> None: """config 的归一化入口要能安全处理脏数据。""" from services.config import _normalize_error_message_rules self.assertEqual(_normalize_error_message_rules(None), []) self.assertEqual(_normalize_error_message_rules([{"keywords": [], "display": ""}]), []) result = _normalize_error_message_rules([{"keywords": ["aa"], "display": "bb"}]) self.assertEqual(result[0]["keywords"], ["aa"]) self.assertEqual(result[0]["display"], "bb") def test_shared_settings_keys_registered(self) -> None: from services.config import SHARED_SETTINGS_KEYS self.assertIn("error_message_rules", SHARED_SETTINGS_KEYS) self.assertIn("error_message_fallback_enabled", SHARED_SETTINGS_KEYS) if __name__ == "__main__": unittest.main()