接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。
- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
(UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
.env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
计数不变(14 + 25 + 40 = 79)。
248 lines
11 KiB
Go
248 lines
11 KiB
Go
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 <key>", 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)
|
||
}
|
||
}
|
||
}
|