First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件覆盖 doJSON 的 HTTP 层错误分级。
|
||||
//
|
||||
// 为什么要单独测:各渠道适配器只管平台自己的业务错误码(钉钉 errcode、
|
||||
// 飞书 code、Telegram ok 字段),而**HTTP 层**的分级是 doJSON 统一做的,
|
||||
// 两者是两道独立的防线。少了这道,一个返回 503 的中转网关会被当成永久失败、
|
||||
// 直接放弃重试;而一个 403 会被当成可重试、白白退避三轮。
|
||||
|
||||
func replyServer(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestDoJSONClassifiesHTTPStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
permanent bool
|
||||
}{
|
||||
{"200 成功不算错误", 200, false},
|
||||
{"429 限流可重试", 429, false},
|
||||
{"408 请求超时可重试", 408, false},
|
||||
{"500 服务端错误可重试", 500, false},
|
||||
{"502 网关错误可重试", 502, false},
|
||||
{"503 服务不可用可重试", 503, false},
|
||||
{"400 参数错误永久失败", 400, true},
|
||||
{"401 鉴权失败永久失败", 401, true},
|
||||
{"403 禁止访问永久失败", 403, true},
|
||||
{"404 地址不存在永久失败", 404, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := replyServer(t, tc.status, `{"detail":"upstream says no"}`)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if tc.status < 300 {
|
||||
if err != nil {
|
||||
t.Fatalf("2xx 不应报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 应报错")
|
||||
}
|
||||
if got := IsPermanent(err); got != tc.permanent {
|
||||
t.Fatalf("HTTP %d 的 permanent 判定错误:期望 %v 得到 %v (%v)",
|
||||
tc.status, tc.permanent, got, err)
|
||||
}
|
||||
// 状态码必须出现在错误里,否则用户无从判断是自己配错了还是对端挂了。
|
||||
// 断言数字而不是 Go 的英文 StatusText:本包的文案是中文的
|
||||
// (与项目其它部分一致),数字才是语言无关、可稳定断言的部分。
|
||||
if !strings.Contains(err.Error(), strconv.Itoa(tc.status)) {
|
||||
t.Errorf("错误信息应带上 HTTP 状态码 %d,得到 %v", tc.status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONIncludesResponseSnippet 覆盖 snippet:对端返回的错误说明要带回来,
|
||||
// 否则用户只知道「失败了」,不知道对端为什么拒绝。
|
||||
func TestDoJSONIncludesResponseSnippet(t *testing.T) {
|
||||
srv := replyServer(t, 400, `{"error":"invalid webhook token"}`)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid webhook token") {
|
||||
t.Errorf("错误信息应带回对端的说明,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONSnippetIsSingleLineAndBounded 约束 snippet 的形态:
|
||||
// 它对端响应会原样进 last_error 列与前端表格,多行/超长会破坏排版与载荷。
|
||||
func TestDoJSONSnippetIsSingleLineAndBounded(t *testing.T) {
|
||||
// 带换行、制表符与 5000 字符超长内容的响应。
|
||||
long := strings.Repeat("x", 5000)
|
||||
srv := replyServer(t, 500, "line1\nline2\r\n\tline3 "+long)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.ContainsAny(msg, "\r\n\t") {
|
||||
t.Errorf("错误信息应压成单行,得到 %q", msg)
|
||||
}
|
||||
// snippet 上限 200 字符 + 固定前缀,总量必须远小于原始响应。
|
||||
if len(msg) > 400 {
|
||||
t.Errorf("错误信息过长(%d 字节),应被 snippet 截断: %q", len(msg), msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONRejectsOversizedResponse 确认读取有上限:对端异常返回超大内容时
|
||||
// 不能把整个响应读进内存(投递历史里每一条都会存一份 last_error)。
|
||||
func TestDoJSONRejectsOversizedResponse(t *testing.T) {
|
||||
huge := strings.Repeat("A", 1<<20) // 1 MiB
|
||||
srv := replyServer(t, 400, huge)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
if len(err.Error()) > 400 {
|
||||
t.Errorf("超大响应应被限长读取并截断,错误信息长度 %d", len(err.Error()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user