package console // 探针的端到端测试:用 httptest 起本地假服务商,验证探针发出的请求形状 // (路径/鉴权头/退化路径)与对各种响应的判定。全程不出网,不碰任何真实服务商。 import ( "context" "net/http" "net/http/httptest" "strings" "testing" "yunyan/comm" ) func ctx(t *testing.T) context.Context { t.Helper() c, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) return c } // TestProbeOpenAICompatOK 正常路径:GET {base}/models 带 Bearer,200 即凭据有效。 func TestProbeOpenAICompatOK(t *testing.T) { var gotPath, gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization") w.WriteHeader(200) _, _ = w.Write([]byte(`{"data":[]}`)) })) defer srv.Close() res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试") if !res.Ok || res.Kind != comm.ProbeCredential { t.Fatalf("期望凭据校验通过,实得 %+v", res) } if gotPath != "/models" { t.Errorf("探测路径 = %q, want /models", gotPath) } if gotAuth != "Bearer sk-1" { t.Errorf("鉴权头 = %q, want 'Bearer sk-1'", gotAuth) } } // TestProbeOpenAICompat401 密钥被拒必须判为不可用,这是巡检要抓的主要故障。 func TestProbeOpenAICompat401(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) })) defer srv.Close() res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "bad", "base_url": srv.URL}, "", "测试") if res.Ok { t.Fatalf("401 必须判为失败,实得 %+v", res) } if !strings.Contains(res.Msg, "invalid api key") { t.Errorf("失败原因应带上服务商返回内容,实得 %q", res.Msg) } } // TestProbeOpenAICompatFallback 中转网关常不实现 /models,此时应退化为最小 chat 请求验鉴权。 func TestProbeOpenAICompatFallback(t *testing.T) { var hitChat bool var chatBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/models" { w.WriteHeader(404) return } if r.URL.Path == "/chat/completions" { hitChat = true buf := make([]byte, r.ContentLength) _, _ = r.Body.Read(buf) chatBody = string(buf) w.WriteHeader(200) _, _ = w.Write([]byte(`{"choices":[]}`)) return } w.WriteHeader(500) })) defer srv.Close() res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL, "model": "test-model"}, "", "测试") if !hitChat { t.Fatal("/models 返回 404 后应退化调用 /chat/completions") } if !res.Ok || res.Kind != comm.ProbeCredential { t.Fatalf("退化路径 200 应判为凭据有效,实得 %+v", res) } // 退化请求必须是最小的,不能真跑一次推理。 if !strings.Contains(chatBody, `"max_tokens":1`) { t.Errorf("退化请求应限制 max_tokens=1,实得 %s", chatBody) } } // TestProbeOpenAICompatFallbackNoModel 没配 model 就无法退化,此时必须如实说「未探测」, // 不能默默判成可用。 func TestProbeOpenAICompatFallbackNoModel(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })) defer srv.Close() res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试") if res.Kind != comm.ProbeNone { t.Fatalf("无法验证时必须标记为未探测,实得 %+v", res) } } func TestProbeOpenAICompatNoKey(t *testing.T) { res := probeOpenAICompat(ctx(t), map[string]string{}, "https://example.invalid", "测试") if res.Ok { t.Fatalf("缺 api_key 应直接判失败,实得 %+v", res) } } // TestProbeAnthropic Anthropic 用 x-api-key + 版本头,头错会被判 401,必须发对。 func TestProbeAnthropicHeaders(t *testing.T) { var key, ver string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key, ver = r.Header.Get("x-api-key"), r.Header.Get("anthropic-version") w.WriteHeader(200) })) defer srv.Close() // 直接验证头部构造:借 doProbe 复现 probeAnthropic 的请求形状。 _, _, err := doProbe(ctx(t), http.MethodGet, srv.URL, map[string]string{"x-api-key": "k", "anthropic-version": "2023-06-01"}, nil) if err != nil { t.Fatal(err) } if key != "k" || ver != "2023-06-01" { t.Errorf("Anthropic 鉴权头不正确: key=%q version=%q", key, ver) } } // TestProbeMCPHandshake MCP 走 JSON-RPC initialize 握手。 func TestProbeMCPHandshake(t *testing.T) { var body string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := make([]byte, r.ContentLength) _, _ = r.Body.Read(buf) body = string(buf) w.WriteHeader(200) _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}`)) })) defer srv.Close() res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"}) if !res.Ok || res.Kind != comm.ProbeCredential { t.Fatalf("握手成功应判可用,实得 %+v", res) } if !strings.Contains(body, `"method":"initialize"`) { t.Errorf("应发送 initialize 握手,实得 %s", body) } } // TestProbeMCPRpcError 服务在线但拒绝初始化,属于要人工看的异常,不能算通过。 func TestProbeMCPRpcError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`)) })) defer srv.Close() res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"}) if res.Ok { t.Fatalf("JSON-RPC error 应判为失败,实得 %+v", res) } } // TestProbeMCPSSE SSE 是长连接流,只能做连通性探测,证据等级必须降级。 func TestProbeMCPSSE(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "1"}) if !res.Ok || res.Kind != comm.ProbeReachable { t.Fatalf("SSE 应判为仅连通性,实得 %+v", res) } } func TestProbeMCPNoURL(t *testing.T) { if res := probeMCP(ctx(t), map[string]string{}); res.Ok { t.Fatalf("缺 url 应判失败,实得 %+v", res) } } // TestHTTPReachable 连不上要判失败;连上任意响应都算地址在线但不验证凭据。 func TestHTTPReachable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) })) defer srv.Close() res := httpReachable(ctx(t), srv.URL) if !res.Ok || res.Kind != comm.ProbeReachable { t.Fatalf("503 也说明地址在线,实得 %+v", res) } srv.Close() if res = httpReachable(ctx(t), srv.URL); res.Ok { t.Fatalf("服务已关闭应判不可达,实得 %+v", res) } } // TestProbeServiceUnknownProvider 未覆盖的服务商:有地址就探连通性,没地址就如实说没探。 func TestProbeServiceUnknownProvider(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() res := probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"base_url": srv.URL}) if res.Kind != comm.ProbeReachable { t.Errorf("未知服务商有地址时应做连通性探测,实得 %+v", res) } res = probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"secret": "x"}) if res.Kind != comm.ProbeNone { t.Errorf("未知服务商且无地址时应标记未探测,实得 %+v", res) } } // TestProbeVolcengine 火山没有免费只读校验接口:字段齐全应如实标记未探测(而不是伪装成通过), // 字段缺失则直接判失败。 func TestProbeVolcengine(t *testing.T) { res := probeVolcengine(map[string]string{"appid": "a", "token": "t"}) if res.Kind != comm.ProbeNone || !res.Ok { t.Errorf("字段齐全应标记未探测,实得 %+v", res) } res = probeVolcengine(map[string]string{"appid": "a"}) if res.Ok { t.Errorf("缺 token 应判失败,实得 %+v", res) } } func TestFirstURLFieldAndPick(t *testing.T) { f := map[string]string{"endpoint": "not-a-url", "base_url": "https://x.test"} if got := firstURLField(f); got != "https://x.test" { t.Errorf("firstURLField = %q, 应跳过非 http 值取到 base_url", got) } if got := pick(f, "missing", "endpoint"); got != "not-a-url" { t.Errorf("pick 应按顺序取第一个非空值,实得 %q", got) } if got := pick(f, "missing"); got != "" { t.Errorf("pick 全缺失应返回空串,实得 %q", got) } }