You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

191 lines
6.7 KiB

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)
}
}
}