Files
nyoung ed51efd8bc fix: 网关与控制台代码审查修复批次(含回归测试)
- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误
- Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐
- TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配
- metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理
- GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示,
  不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚
- set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、
  限流或求解开关按零值清掉
- 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限
- main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部
  挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray
- proxypool / turnstile / upstream / openai / config / httpx / logbuf 等
  逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
2026-10-08 15:13:23 +08:00

256 lines
7.5 KiB
Go
Raw Permalink 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 upstream
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/nyoungo/dsfree2api/internal/httpx"
)
// 回归:SSE 规范要求同一事件的多个 data 行用 "\n" 拼接后再解析。逐行单独
// json.Unmarshal 会把跨行 JSON 整条丢弃,导致该事件静默消失。
func TestTranslateEventJoinsMultiLineData(t *testing.T) {
evs, err := translateEvent("message", []string{`{"delta":`, `"hello"}`})
if err != nil {
t.Fatalf("translate: %v", err)
}
if len(evs) != 1 || evs[0].Kind != KindDelta || evs[0].Value != "hello" {
t.Fatalf("events = %+v, want one delta %q", evs, "hello")
}
}
// 回归:readSSE → translateEvent 全链路上,跨行 data 的事件不能丢失,
// 单行事件与 [DONE] 必须照常工作。
func TestReadSSEMultiLineDataEvent(t *testing.T) {
const body = "event: message\n" +
"data: {\"delta\":\n" +
"data: \"hello\"}\n" +
"\n" +
"data: [DONE]\n" +
"\n"
var got []Event
if err := readSSE(strings.NewReader(body), func(event string, lines []string) error {
evs, err := translateEvent(event, lines)
got = append(got, evs...)
return err
}); err != nil {
t.Fatalf("readSSE: %v", err)
}
if len(got) != 2 {
t.Fatalf("events = %+v, want delta + done", got)
}
if got[0].Kind != KindDelta || got[0].Value != "hello" {
t.Fatalf("first event = %+v, want delta %q", got[0], "hello")
}
if got[1].Kind != KindDone {
t.Fatalf("second event = %+v, want done", got[1])
}
}
// 前提:httpx.Stream 对 >=400 把状态码连同错误一起返回,调用方拿得到 401。
// (chatOnce 里 `rc, _, err := sess.Stream(...)` 把它丢掉了。)
func TestStreamErrorCarriesHTTPStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "expired", http.StatusUnauthorized)
}))
defer srv.Close()
sess, err := httpx.NewSession(httpx.Options{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("session: %v", err)
}
defer sess.Close()
rc, status, err := sess.Stream(t.Context(), httpx.Request{Method: http.MethodGet, URL: srv.URL})
if err == nil {
if rc != nil {
rc.Close()
}
t.Fatalf("status %d should have returned an error", status)
}
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized)
}
}
// 回归:流建立阶段的 401/403 必须归类为 session 错误,否则 failover 循环会
// 走 poolReport(proxy, false) 把出口代理当故障标记,同时不会刷新 cookie,
// 带着同一份过期 cookie 重试到超时。
func TestStreamHTTPErrorClassifiesUnauthorizedAsSession(t *testing.T) {
for _, code := range []int{http.StatusUnauthorized, http.StatusForbidden} {
err := streamHTTPError(code, errors.New("upstream status"))
if !isSession(err) {
t.Fatalf("streamHTTPError(%d) = %#v, want session error", code, err)
}
}
if isSession(streamHTTPError(http.StatusBadGateway, errors.New("upstream status"))) {
t.Fatalf("502 must stay a plain upstream error")
}
}
// 回归:并发闸门的容量必须跟随 [limits].max_concurrent_per_site 变更。旧实现
// 用"首次 acquire 时定容的 channel",改配置(控制台或 PUT /api/config)后要
// 重启才生效。
func TestConcurrencyGateFollowsConfigChanges(t *testing.T) {
cfg := testConfig(t)
cfg.Limits.MaxConcurrentPerSite = 4
c := New(cfg, nil, nil, nil)
ctx := context.Background()
// 先按 4 建一次闸门
if err := c.acquire(ctx, "site-a"); err != nil {
t.Fatalf("first acquire: %v", err)
}
c.release("site-a")
cfg.Limits.MaxConcurrentPerSite = 1
if err := c.acquire(ctx, "site-a"); err != nil {
t.Fatalf("新上限下的第一个名额: %v", err)
}
short, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
if err := c.acquire(short, "site-a"); err == nil {
c.release("site-a")
t.Fatalf("上限已改成 1,第二个并发请求却拿到了名额")
}
c.release("site-a")
if err := c.acquire(ctx, "site-a"); err != nil {
t.Fatalf("release 之后应能拿到名额: %v", err)
}
c.release("site-a")
}
// 回归:上限必须真的挡住并发(防止把闸门改坏)。
func TestConcurrencyGateBlocksBeyondLimit(t *testing.T) {
cfg := testConfig(t)
cfg.Limits.MaxConcurrentPerSite = 2
c := New(cfg, nil, nil, nil)
ctx := context.Background()
if err := c.acquire(ctx, "site-b"); err != nil {
t.Fatalf("slot 1: %v", err)
}
if err := c.acquire(ctx, "site-b"); err != nil {
t.Fatalf("slot 2: %v", err)
}
short, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
if err := c.acquire(short, "site-b"); err == nil {
t.Fatalf("第三个名额不该发出去")
}
c.release("site-b")
c.release("site-b")
if err := c.acquire(ctx, "site-b"); err != nil {
t.Fatalf("release 后应恢复: %v", err)
}
c.release("site-b")
}
// 回归:0 = 不限并发(与 config.example.toml 的注释一致)。
func TestConcurrencyGateZeroMeansUnlimited(t *testing.T) {
cfg := testConfig(t)
cfg.Limits.MaxConcurrentPerSite = 0
c := New(cfg, nil, nil, nil)
ctx := context.Background()
for i := 0; i < 8; i++ {
if err := c.acquire(ctx, "site-c"); err != nil {
t.Fatalf("acquire #%d: %v", i, err)
}
}
for i := 0; i < 8; i++ {
c.release("site-c")
}
}
// 回归:并发增删 + 中途改上限,不能漏发名额也不能多发(-race 下验证记账)。
func TestConcurrencyGateUnderConcurrentLoad(t *testing.T) {
cfg := testConfig(t)
cfg.Limits.MaxConcurrentPerSite = 3
c := New(cfg, nil, nil, nil)
var wg sync.WaitGroup
stop := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 40; i++ {
cfg.Lock()
cfg.Limits.MaxConcurrentPerSite = []int{1, 3, 5, 0}[i%4]
cfg.Unlock()
time.Sleep(2 * time.Millisecond)
}
close(stop)
}()
for w := 0; w < 4; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
if err := c.acquire(ctx, "site-d"); err == nil {
c.release("site-d")
}
cancel()
}
}()
}
wg.Wait()
}
// 回归:错误信息里的字节截断不能切碎多字节字符。
func TestUpstreamTruncateKeepsUTF8Valid(t *testing.T) {
got := truncate(strings.Repeat("汉", 100), 10)
if !utf8.ValidString(got) {
t.Fatalf("truncate 切出了非法 UTF-8: %q", got)
}
}
// 回归:shortJSON 截断同样要落在字符边界上。
func TestShortJSONKeepsUTF8Valid(t *testing.T) {
got := shortJSON(map[string]any{
"aaa": strings.Repeat("a", 200),
"error": strings.Repeat("汉", 200),
})
if !utf8.ValidString(got) {
t.Fatalf("shortJSON 切出了非法 UTF-8: %q", got)
}
}
// 回归:quota 冷却在窗口内被重复触发时只记一次 INFO(注释说的
// "the INFO line marks the transition, not every probe that confirms it"),
// 但冷却截止时间每次都要顺延。
func TestQuotaCoolLogsOncePerTransition(t *testing.T) {
cfg := testConfig(t)
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
c := New(cfg, nil, log, nil)
c.setQuotaCool("site-a")
first := c.quotaCool["site-a"]
time.Sleep(5 * time.Millisecond)
c.setQuotaCool("site-a")
second := c.quotaCool["site-a"]
if !second.After(first) {
t.Fatalf("重复触发没有顺延冷却时间: %v -> %v", first, second)
}
if n := strings.Count(buf.String(), "site quota cooldown"); n != 1 {
t.Fatalf("冷却 INFO 条数 = %d, want 1\nlog: %s", n, buf.String())
}
}