package search import ( "context" "errors" "net/http" "strings" "testing" ) // 本文件钉住腾讯云·元宝搜索 provider 的**请求面**、**Pages 二次解析**与**错误映射** // (依据:DevDocs/reference/search-providers/tencent-wsa 的官方原文 + 无 key 本机实测 // `live-probe-2026-10-07.md`)。 // // 两条本家特有的口径,各有用例守着: // // - `Pages` 是 **JSON 字符串数组**(官方类型 `Array of String`)⇒ 必须二次解析; // - **HTTP 状态码恒为 200**,失败信息在 `Response.Error.Code` ⇒ 判错只能读响应体 // (用例里那个假上游**确实回的是 200**,这就是「读响应体」这条路径的证据)。 // // 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测)。 // tencentWSAOK 是官方示例形状的应答(`api-searchpro-apikey.md` 输出示例逐字段照抄): // `Pages` 的元素是**一段 JSON 文本**,不是对象。 const tencentWSAOK = `{ "Response": { "Pages": [ "{\"passage\":\"三星堆名字的由来 三星堆遗址位于四川省广汉市西北部\",\"score\":0.897802,\"date\":\"2024-06-07 19:00:51\",\"title\":\"三星堆名字的由来\",\"url\":\"https://www.163.com/dy/article/J43Q2STU0553FV3Z.html\",\"site\":\"网易\",\"images\":[],\"favicon\":\"http://img01.sogoucdn.com/app/a/200913/x.png\"}", "{\"passage\":\"一、地理环境因素\",\"score\":0.8887853,\"date\":\"2024-09-03 12:59:00\",\"title\":\"三星堆名字的由来_腾讯新闻\",\"url\":\"https://news.qq.com/rain/a/20240903A048N700\",\"site\":\"腾讯网\"}" ], "Query": "三星堆的由来", "Version": "standard", "RequestId": "6f8df221-9a68-4ea2-90d6-a9590cae244c" } }` // TestTencentWSARequestShape 钉住请求面:POST `/SearchPro` + Bearer,请求体只带官方必选的 // `Query`(版本门槛参数与域名过滤参数一律不发 —— 理由见 provider 文件头)。 func TestTencentWSARequestShape(t *testing.T) { t.Parallel() srv, cap := fixtureServer(t, tencentWSAOK) if _, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "昔涟", Count: 20}); err != nil { t.Fatalf("Search: %v", err) } if cap.req == nil { t.Fatal("没发出任何请求") } if cap.req.Method != http.MethodPost { t.Errorf("method = %q, want POST", cap.req.Method) } if cap.req.URL.Path != tencentWSAPath { t.Errorf("path = %q, want %q", cap.req.URL.Path, tencentWSAPath) } if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey { t.Errorf("Authorization = %q, want Bearer ", got) } var body map[string]any mustUnmarshal(t, cap.body, &body) if body["Query"] != "昔涟" { t.Errorf("body Query = %v, want 昔涟", body["Query"]) } if len(body) != 1 { t.Errorf("body = %v,只许发官方必选的 Query(Cnt 是尊享版门槛、Site 是域名过滤)", body) } } // TestTencentWSAPagesAreJSONStrings 是验收 ①:`Pages` 的**二次解析**。官方把每条结果编码成 // 一段 JSON 字符串 ⇒ 拿对象数组直接喂共享提取器会得到 ErrBadResponse;本家的解析能吃下它, // 并把 title/url/passage/date/score 归一化到位。 func TestTencentWSAPagesAreJSONStrings(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tencentWSAOK) got, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "三星堆的由来"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 2 { t.Fatalf("结果条数 = %d, want 2(Pages 里两条 JSON 文本): %+v", len(got), got) } first := got[0] if first.Title != "三星堆名字的由来" { t.Errorf("Title = %q", first.Title) } if first.Link != "https://www.163.com/dy/article/J43Q2STU0553FV3Z.html" { t.Errorf("Link = %q", first.Link) } if !strings.HasPrefix(first.Snippet, "三星堆名字的由来 三星堆遗址") { t.Errorf("Snippet = %q(官方 passage = 标准摘要)", first.Snippet) } if first.Content == "" { t.Error("Content 为空:官方这条没有 content(动态摘要)⇒ 包内既有口径应回落 passage") } if first.Published != "2024-06-07 19:00:51" { t.Errorf("Published = %q, want 官方 date 原样", first.Published) } if first.Score <= 0 { t.Errorf("Score = %v, want 0.897802(官方 score 0~1)", first.Score) } // site(网站名称)不许被投影进 Snippet:那是「正文摘录」的语义位(同 zhipuSpec 的理由)。 if strings.Contains(first.Snippet, "网易") { t.Errorf("Snippet = %q 里出现了站点名「网易」(官方 site 字段)—— 站点名不进 Snippet", first.Snippet) } } // TestTencentWSAContentFallsBackToPassage 钉住「尊享版的 content 拿不到时不空着」: // 官方 `content` 是尊享版字段(极速版不返回)⇒ 缺失时 Content 回落标准摘要 passage。 func TestTencentWSAContentFallsBackToPassage(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"Response":{"Pages":["{\"title\":\"t\",\"url\":\"https://x/1\",\"passage\":\"标准摘要\"}"]}}`) got, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("条数 = %d, want 1", len(got)) } if got[0].Content != "标准摘要" { t.Errorf("Content = %q, want 回落 passage(官方 content 是尊享版字段,可能缺)", got[0].Content) } } // TestTencentWSADirtyPageElementIsSkipped 钉住容错:解析不出来的页元素(不是 JSON、也不是 // 对象)跳过,不毁掉整次检索。 func TestTencentWSADirtyPageElementIsSkipped(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"Response":{"Pages":["not-json",{"title":"对象形态也认"},"{\"title\":\"正常\"}"]}}`) got, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("单条脏数据不该让整次检索报错: %v", err) } if len(got) != 2 { t.Fatalf("条数 = %d, want 2(坏的那条应被跳过): %+v", len(got), got) } } // TestTencentWSAErrorsOnHTTP200 是验收 ②:**HTTP 200 + `Response.Error`** 这条路径 // (假上游回的确实就是 200 —— 它的 handler 从不写状态码)。三个官方码各一条, // 且分类要一路走到探针词汇(面板「测试连通」据此说话)。 func TestTencentWSAErrorsOnHTTP200(t *testing.T) { t.Parallel() cases := []struct { name string code string wantSent error wantCode string }{ {"key 无效 / 无 key(同码)", "UnauthorizedOperation", ErrUnauthorized, ProbeUnauthorized}, {"欠费", "ResourceUnavailable", ErrQuotaExceeded, ProbeQuotaExceeded}, {"频率超限", "RequestLimitExceeded", ErrRateLimited, ProbeRateLimited}, {"点分限流码(前缀匹配)", "RequestLimitExceeded.UinLimitExceeded", ErrRateLimited, ProbeRateLimited}, {"未开通服务", "ResourceNotFound", ErrQuotaExceeded, ProbeQuotaExceeded}, {"超出配额", "LimitExceeded", ErrQuotaExceeded, ProbeQuotaExceeded}, {"鉴权前缀码", "AuthFailure.SignatureFailure", ErrUnauthorized, ProbeUnauthorized}, {"表里没有的码 ⇒ upstream", "InternalError", ErrUpstream, ProbeUpstream}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"Response":{"Error":{"Code":"`+tc.code+`","Message":"`+testKey+` 不应进文案"},"RequestId":"r"}}`) got, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err == nil { t.Fatalf("HTTP 200 带 Response.Error 必须报失败,却拿到 %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 !strings.Contains(err.Error(), tc.code) { t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志定位): %v", tc.code, err) } // 上游 Message 是任意文本(可能回显凭据)⇒ 不许进错误文案。 if strings.Contains(err.Error(), testKey) { t.Fatalf("错误文案带上了上游 Message / 凭据: %v", err) } }) } } // TestTencentWSAEmptyPagesIsNotAnError 是验收 ③:`Pages: []` 是「上游正常应答但零结果」, // **不是错误**(由上层按「空结果 ≠ 成功」处置),也不是结构错。 func TestTencentWSAEmptyPagesIsNotAnError(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}`) got, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("空 Pages 不是错误: %v", err) } if len(got) != 0 { t.Fatalf("条数 = %d, want 0", len(got)) } } // TestTencentWSAMissingPagesIsBadResponse 钉住「空」与「解析不了」分开:连 `Pages` 都没有 // (响应形状变了)⇒ ErrBadResponse,而不是静默当成 0 条。 func TestTencentWSAMissingPagesIsBadResponse(t *testing.T) { t.Parallel() for _, body := range []string{`{"Response":{"Query":"q"}}`, `{"Response":{"Pages":"不是数组"}}`, `not json`, `{}`} { srv, _ := fixtureServer(t, body) _, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if !errors.Is(err, ErrBadResponse) { t.Errorf("输入 %q 应报 ErrBadResponse, got %v", body, err) } } } // TestTencentWSAOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒ // ErrResponseTooLarge(**不静默截断成半截 JSON**)。 func TestTencentWSAOverLimitResponseRejected(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tencentWSAOK) _, err := newTencentWSA(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16, }).Search(context.Background(), Query{Text: "q"}) if !errors.Is(err, ErrResponseTooLarge) { t.Fatalf("err = %v, want ErrResponseTooLarge", err) } } // TestTencentWSACodeClassIsLocal 钉住这张码表**只服务本家**:既有的公共码表 // (errors.go 的 upstreamCodeClass,被所有家共用)不许认腾讯云的大写点分码 —— 认了会让 // 「按码值碰运气」的隐患跨家扩散。 func TestTencentWSACodeClassIsLocal(t *testing.T) { t.Parallel() for _, code := range []string{"UnauthorizedOperation", "ResourceUnavailable", "RequestLimitExceeded"} { if _, ok := upstreamCodeClass(code); ok { t.Errorf("upstreamCodeClass(%q) 不该认识腾讯云的业务码(它随 HTTP 200 返回,只在本家映射)", code) } } }