package console // 巡检的纯逻辑单测:配置校验规则、状态码判定、签名编码、探测去重键。 // 这些是巡检结论的判据,判错会直接变成误报/漏报,值得钉死。 // 真实探针(会打第三方接口)不在单测覆盖范围内。 import ( "strings" "testing" "yunyan/comm" ) func TestCheckSvcConfig(t *testing.T) { encField := SvcField{Key: "api_key", Encrypted: true} plainField := SvcField{Key: "model"} tests := []struct { name string svc *ThirdSvcConfig ovr *SvcRegionOverride cats map[int32]bool fields map[string]string wantFatal bool wantHas string // 期望问题清单里包含的关键字,空=不检查 }{ { name: "凭据齐全且勾了类别,无任何问题", svc: &ThirdSvcConfig{Categories: "5", Fields: []SvcField{encField, plainField}}, cats: map[int32]bool{svcCatLLM: true}, fields: map[string]string{"api_key": "sk-xxx", "model": "gpt-4o"}, }, { name: "加密字段为空判致命", svc: &ThirdSvcConfig{Categories: "5", Fields: []SvcField{encField}}, cats: map[int32]bool{svcCatLLM: true}, fields: map[string]string{"api_key": " "}, wantFatal: true, wantHas: "api_key", }, { name: "被区域停用的加密字段不算缺失", svc: &ThirdSvcConfig{Categories: "5", Fields: []SvcField{encField}}, ovr: &SvcRegionOverride{DisabledKeys: []string{"api_key"}}, cats: map[int32]bool{svcCatLLM: true}, // 该区域本就不用这个字段,为空是正常的。 fields: map[string]string{}, }, { name: "区域专属新增的加密字段为空同样判致命", svc: &ThirdSvcConfig{Categories: "5", Fields: []SvcField{encField}}, ovr: &SvcRegionOverride{ExtraFields: []SvcField{{Key: "region_key", Encrypted: true}}}, cats: map[int32]bool{svcCatLLM: true}, fields: map[string]string{"api_key": "sk-xxx", "region_key": ""}, wantFatal: true, wantHas: "region_key", }, { name: "MCP 缺 url 判致命", svc: &ThirdSvcConfig{Categories: "10"}, cats: map[int32]bool{svcCatMCP: true}, fields: map[string]string{}, wantFatal: true, wantHas: "MCP", }, { name: "未勾类别只是隐患,不致命", svc: &ThirdSvcConfig{Categories: "", Fields: []SvcField{encField}}, cats: map[int32]bool{}, fields: map[string]string{"api_key": "sk-xxx"}, wantHas: "未勾选服务类别", }, { name: "TTS 缺 languages 只是隐患", svc: &ThirdSvcConfig{Categories: "2", Fields: []SvcField{encField}}, cats: map[int32]bool{svcCatTTS: true}, fields: map[string]string{"api_key": "sk-xxx", "languages": ""}, wantHas: "languages", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { issues := checkSvcConfig(tc.svc, tc.ovr, tc.cats, tc.fields) if got := hasFatalIssue(issues); got != tc.wantFatal { t.Fatalf("hasFatalIssue=%v, want %v;问题清单=%v", got, tc.wantFatal, issues) } if tc.wantHas != "" && !strings.Contains(strings.Join(issues, "|"), tc.wantHas) { t.Fatalf("问题清单 %v 中未包含 %q", issues, tc.wantHas) } if tc.wantHas == "" && !tc.wantFatal && len(issues) != 0 { t.Fatalf("期望无问题,实得 %v", issues) } }) } } func TestClassify(t *testing.T) { tests := []struct { status int wantOk bool wantKind string }{ {200, true, comm.ProbeCredential}, {204, true, comm.ProbeCredential}, {401, false, comm.ProbeCredential}, // 凭据无效——要运营去换密钥 {403, false, comm.ProbeCredential}, // {429, true, comm.ProbeCredential}, // 限流说明凭据是被接受的 {404, true, comm.ProbeNone}, // 端点不适用,不能算故障,但也没验证到 {405, true, comm.ProbeNone}, // {500, false, comm.ProbeCredential}, // 服务商故障 {418, false, comm.ProbeCredential}, // 其它异常 } for _, tc := range tests { got := classify(tc.status, "body", "测试服务") if got.Ok != tc.wantOk || got.Kind != tc.wantKind { t.Errorf("classify(%d) = {Ok:%v Kind:%s}, want {Ok:%v Kind:%s}", tc.status, got.Ok, got.Kind, tc.wantOk, tc.wantKind) } } } // TestAliyunEncode 阿里云 RPC 签名要求的 percent-encode 与 Go 默认 QueryEscape 有三处差异, // 编码错会让签名恒不匹配,把「AK 正常」误报成「AK 无效」。 func TestAliyunEncode(t *testing.T) { tests := [][2]string{ {"a b", "a%20b"}, // 空格必须是 %20 而不是 + {"a*b", "a%2Ab"}, // * 必须转义 {"a~b", "a~b"}, // ~ 必须保持原样 {"a/b", "a%2Fb"}, // {"/", "%2F"}, // {"k=v&x=y", "k%3Dv%26x%3Dy"}, } for _, tc := range tests { if got := aliyunEncode(tc[0]); got != tc[1] { t.Errorf("aliyunEncode(%q) = %q, want %q", tc[0], got, tc[1]) } } } // TestProbeCacheKey 去重键必须与字段顺序无关(map 遍历顺序随机), // 且任一字段值不同就必须是不同的键——否则会把 A 区域的探测结论错安到 B 区域头上。 func TestProbeCacheKey(t *testing.T) { a := probeCacheKey("openai", map[string]string{"api_key": "k1", "model": "gpt-4o"}) b := probeCacheKey("openai", map[string]string{"model": "gpt-4o", "api_key": "k1"}) if a != b { t.Fatal("同一份配置在不同 map 遍历顺序下应得到相同的键") } if a == probeCacheKey("openai", map[string]string{"api_key": "k2", "model": "gpt-4o"}) { t.Fatal("凭据不同必须得到不同的键") } if a == probeCacheKey("deepseek", map[string]string{"api_key": "k1", "model": "gpt-4o"}) { t.Fatal("服务商不同必须得到不同的键") } // 键分隔符不能被字段值本身伪造出碰撞。 x := probeCacheKey("p", map[string]string{"a": "1", "b": "2"}) y := probeCacheKey("p", map[string]string{"a": "1\x01b\x012"}) if x == y { t.Fatal("不同字段结构不应碰撞") } } func TestCatSet(t *testing.T) { got := catSet("5, 9 ,10") for _, want := range []int32{svcCatLLM, svcCatLLMVision, svcCatMCP} { if !got[want] { t.Errorf("catSet 未解析出类别 %d", want) } } if len(catSet("")) != 0 || len(catSet("abc")) != 0 { t.Error("空值/非法值应解析为空集") } } // TestSortHealth 巡检页与告警条都靠它排序:不可用的必须排在最前,同级里一直坏着的优先。 func TestSortHealth(t *testing.T) { rows := []*comm.SvcHealth{ {Name: "c", Status: comm.HealthOK}, {Name: "d", Status: comm.HealthSkip}, {Name: "a", Status: comm.HealthError, FailStreak: 1}, {Name: "b", Status: comm.HealthWarn}, {Name: "e", Status: comm.HealthError, FailStreak: 9}, } sortHealth(rows) want := []string{"e", "a", "b", "c", "d"} for i, w := range want { if rows[i].Name != w { t.Fatalf("排序结果第 %d 位是 %s,期望 %s", i, rows[i].Name, w) } } }