Files
Cyrene_v2/plugins/tools/io/search/tencent_wsa_test.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
2026-10-07 13:42:41 +08:00

248 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}