package search import ( "context" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) // 本文件钉住智谱 provider 的**请求面**与**错误映射**(依据:分册 11「官方 schema 确证(2026-10-07)」): // 官方请求 9 字段(`search_query` ≤70 字 / `search_intent` boolean 必填 / `count` 1–50 / `content_size`)、 // 响应 `search_result[]` 七键、错误码 `error.code`(🔴 1113 欠费与 1302/1305 限流**同为 HTTP 429**)。 // 全部打 httptest,**不打真实网络**(无 key,真机未实测)。 // zhipuOK 是官方文档原文形状的应答(分册 11 响应示例逐字段照抄),含语义易混的 // 响应侧 `search_intent`(**object[]**,与请求侧 boolean 反向)。 const zhipuOK = `{ "created": 1748261757, "id": "20250526201557dda85ca6801b467b", "request_id": "20250526201557dda85ca6801b467b", "search_intent": [ {"intent": "SEARCH_ALL", "keywords": "2025年4月 财经新闻", "query": "搜索2025年4月的财经新闻"} ], "search_result": [ { "content": "一、1-4月我国对外直接投资575.4亿美元,同比增长7.5%。", "icon": "https://sfile.chatglm.cn/searchImage/sohu_icon_new.jpg", "link": "https://www.sohu.com/a/897879632_121123890", "media": "搜狐", "publish_date": "2025-05-23", "refer": "ref_1", "title": "2025年5月23日财经早资讯" } ] }` // zhipuStub 是一个假智谱上游:回固定状态码 + 固定报文,并数着被打了多少次 // (用来证明「超长查询词根本不发请求」)。 type zhipuStub struct { srv *httptest.Server hits int32 body chan string } func newZhipuStub(t *testing.T, status int, fixture string) *zhipuStub { t.Helper() st := &zhipuStub{body: make(chan string, 8)} st.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&st.hits, 1) raw, _ := io.ReadAll(r.Body) select { case st.body <- string(raw): default: } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(fixture)) })) t.Cleanup(st.srv.Close) return st } func (st *zhipuStub) provider() Provider { return newZhipu(Config{APIKey: testKey, BaseURL: st.srv.URL, Client: st.srv.Client(), MaxBytes: 1 << 20}) } func (st *zhipuStub) hitCount() int { return int(atomic.LoadInt32(&st.hits)) } // lastBody 取最近一次请求体(一次都没发过时返回 "")。 func (st *zhipuStub) lastBody() string { select { case b := <-st.body: return b default: return "" } } // decodedBody 把最近一次请求体解成 map;没发过请求即 Fatal。 func (st *zhipuStub) decodedBody(t *testing.T) map[string]any { t.Helper() raw := st.lastBody() if raw == "" { t.Fatal("没收到任何请求体 —— 请求根本没发出去") } var body map[string]any mustUnmarshal(t, raw, &body) return body } // 判据 ①:请求体补发官方标 required / 半确证的字段(`search_intent:false` + `content_size:"medium"`), // 合法 `count` 透传。 func TestZhipuRequestCarriesConfirmedFields(t *testing.T) { t.Parallel() st := newZhipuStub(t, http.StatusOK, zhipuOK) got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 5}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("结果条数 = %d, want 1", len(got)) } body := st.decodedBody(t) if body["search_intent"] != false { t.Errorf("search_intent = %v, want false(官方标 required,缺发可能触发 1213)", body["search_intent"]) } if body["content_size"] != zhipuContentSize { t.Errorf("content_size = %v, want %q", body["content_size"], zhipuContentSize) } if body["count"] != float64(5) { t.Errorf("count = %v, want 5(合法区间内应透传)", body["count"]) } if body["search_engine"] != zhipuEngineStd { t.Errorf("search_engine = %v, want %q", body["search_engine"], zhipuEngineStd) } if body["search_query"] != "昔涟" { t.Errorf("search_query = %v, want 昔涟", body["search_query"]) } } // 判据 ②:`count` 落在官方区间 [1,50] 才透传;越界(0 / 负数 / >50)**不发这个字段**, // 让上游用默认 10(不许把非法值发上去)。 func TestZhipuCountOnlySentWithinOfficialRange(t *testing.T) { t.Parallel() cases := []struct { count int want bool }{ {0, false}, {1, true}, {10, true}, {50, true}, {51, false}, {-3, false}, } for _, tc := range cases { tc := tc t.Run(fmt.Sprintf("count=%d", tc.count), func(t *testing.T) { t.Parallel() st := newZhipuStub(t, http.StatusOK, zhipuOK) if _, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: tc.count}); err != nil { t.Fatalf("Search: %v", err) } raw, present := st.decodedBody(t)["count"] if !tc.want { if present { t.Fatalf("count=%d 越界却把非法值发了上去: %v", tc.count, raw) } return } if !present || raw != float64(tc.count) { t.Fatalf("count = %v (present=%v), want %d", raw, present, tc.count) } }) } } // 判据 ③:`search_query` 超过官方 70 字硬上限 ⇒ **如实报错**(不截断),且**一个请求都不发** // (服务器计数为 0 是证据)。边界:正好 70 字放行。 func TestZhipuQueryOverLimitFailsWithoutRequest(t *testing.T) { t.Parallel() t.Run("71 字必须报错且不发请求", func(t *testing.T) { t.Parallel() st := newZhipuStub(t, http.StatusOK, zhipuOK) // 用中文:证明按**字符**(rune)而不是字节计数(71 字 = 213 字节)。 got, err := st.provider().Search(context.Background(), Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes+1), Count: 3}) if err == nil { t.Fatalf("超长查询词必须如实报错,却拿到了 %d 条结果", len(got)) } if !strings.Contains(err.Error(), "70") { t.Errorf("错误文案应点明官方上限 70:%v", err) } if n := st.hitCount(); n != 0 { t.Fatalf("超长查询词不该发出请求,服务器却收到 %d 次", n) } }) t.Run("70 字是上限内,应放行", func(t *testing.T) { t.Parallel() st := newZhipuStub(t, http.StatusOK, zhipuOK) if _, err := st.provider().Search(context.Background(), Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes), Count: 1}); err != nil { t.Fatalf("正好 %d 字在官方上限内,不该报错: %v", zhipuMaxQueryRunes, err) } if n := st.hitCount(); n != 1 { t.Fatalf("正好 %d 字应发出 1 次请求,实际 %d 次", zhipuMaxQueryRunes, n) } }) } // 判据 ④:错误映射 —— 🔴 429 上的「欠费(1113)」与「限流(1302/1305)」必须靠 `error.code` 分开, // 不许只看 HTTP 状态;分类要一路走到探针词汇(面板「测试连通」据此说话)。 func TestZhipuErrorCodeMapping(t *testing.T) { t.Parallel() cases := []struct { name string status int body string wantSent error wantCode string // 探针错误码(probe.go 的词汇) wantIn string // 错误文案里必须带上的原始业务码(供日志定位) }{ { name: "凭据无效 1001", status: http.StatusUnauthorized, body: `{"error":{"code":"1001","message":"Header 中未收到 Authentication 参数,无法进行身份验证"}}`, wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1001", }, { name: "凭据无效 1000", status: http.StatusUnauthorized, body: `{"error":{"code":"1000","message":"身份验证失败"}}`, wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1000", }, { name: "凭据过期 1003(数字码也要认)", status: http.StatusUnauthorized, body: `{"error":{"code":1003,"message":"Authentication Token 已过期,请重新生成"}}`, wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1003", }, { name: "欠费 1113(同为 429,必须与限流分开)", status: http.StatusTooManyRequests, body: `{"error":{"code":"1113","message":"您的账户已欠费,请充值后重试"}}`, wantSent: ErrQuotaExceeded, wantCode: ProbeQuotaExceeded, wantIn: "1113", }, { name: "账户限流 1302", status: http.StatusTooManyRequests, body: `{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}`, wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1302", }, { name: "平台过载 1305", status: http.StatusTooManyRequests, body: `{"error":{"code":"1305","message":"该模型当前访问量过大,请您稍后再试"}}`, wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1305", }, { name: "429 + 表里没有的码 ⇒ 保守当限流,但码要进文案", status: http.StatusTooManyRequests, body: `{"error":{"code":"1701","message":"并发已达上限"}}`, wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1701", }, { name: "429 + 没有码 ⇒ 保守当限流", status: http.StatusTooManyRequests, body: `{}`, wantSent: ErrRateLimited, wantCode: ProbeRateLimited, }, { name: "5xx ⇒ upstream", status: http.StatusBadGateway, body: `bad gateway`, wantSent: ErrUpstream, wantCode: ProbeUpstream, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() st := newZhipuStub(t, tc.status, tc.body) got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1}) if err == nil { t.Fatalf("非 2xx 必须如实报错,却拿到 %d 条结果", len(got)) } if !errors.Is(err, tc.wantSent) { t.Errorf("errors.Is(err, %v) = false,错误分类错了: %v", tc.wantSent, err) } if code := ProbeErrorCode(err); code != tc.wantCode { t.Errorf("探针错误码 = %q, want %q(面板文案会因此说错话)", code, tc.wantCode) } if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) { t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志区分欠费/限流):%v", tc.wantIn, err) } if strings.Contains(err.Error(), testKey) { t.Fatalf("错误文案泄漏了 API key: %v", err) } }) } } // 判据 ⑤:官方确证的键能被归一化 —— 响应侧 `search_intent` 是 **object[]**(与请求侧 boolean 反向), // 不许被误当成结果数组;`publish_date` 真的被吃进来,而 `media`(网站名称)**不许**被投影到 Snippet。 func TestZhipuResponseNormalizesConfirmedKeys(t *testing.T) { t.Parallel() st := newZhipuStub(t, http.StatusOK, zhipuOK) got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("结果条数 = %d, want 1(search_intent 是意图数组,不该被当成结果数组)", len(got)) } r := got[0] if r.Title != "2025年5月23日财经早资讯" { t.Errorf("Title = %q", r.Title) } if r.Link != "https://www.sohu.com/a/897879632_121123890" { t.Errorf("Link = %q", r.Link) } if !strings.HasPrefix(r.Content, "一、1-4月") { t.Errorf("Content = %q(官方「摘要」就在 content 里)", r.Content) } if r.Published != "2025-05-23" { t.Errorf("Published = %q, want 2025-05-23(官方 publish_date)", r.Published) } // 🔴 官方响应**没有 `snippet`** ⇒ 若把 `media`(网站名称,如「搜狐」)当候选塞进 `Snippet`, // 它会被**恒定填满**(不是偶尔兜底),而 Snippet 的语义是**正文摘录**;更糟的是共享提取器 // 有一条单向回落(`Content` 空 ⇒ 回落 `Snippet`)⇒ 站点名可能被当成正文递出去。 if strings.Contains(r.Snippet, "搜狐") { t.Errorf("Snippet = %q 里出现了站点名「搜狐」(官方 media=网站名称)—— media/icon/refer 当前一律不投影", r.Snippet) } if r.Snippet != "" { t.Errorf("Snippet = %q, want 空(官方响应没有 snippet,Snippet 不许被别的字段填充)", r.Snippet) } }