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

100 lines
2.6 KiB
Go

package api
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/nyoungo/dsfree2api/internal/config"
"github.com/nyoungo/dsfree2api/internal/logbuf"
"github.com/nyoungo/dsfree2api/internal/metrics"
"github.com/nyoungo/dsfree2api/internal/turnstile"
"github.com/nyoungo/dsfree2api/internal/upstream"
)
func newTestServerWithConfig(t *testing.T, cfg *config.Config) *Server {
t.Helper()
met := metrics.New(t.TempDir())
met.Start()
t.Cleanup(met.Close)
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ts := turnstile.New(cfg.Turnstile, cfg.Upstream.UserAgent)
up := upstream.New(cfg, ts, log, nil)
return New(cfg, up, met, logbuf.New(10), ts, log)
}
// 回归:一次流式请求只能计一次 in_flight。handleChat 已经计过,streamChat
// 里又计一次,控制台的并发数会按 2 倍显示。
func TestStreamingRequestCountsInFlightOnce(t *testing.T) {
// 假站点:每个请求拖 300ms,好让测试在请求进行中采样并发数
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html><body>nope</body></html>"))
}))
t.Cleanup(hang.Close)
cfg, err := config.Load(examplePath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.Turnstile.Enabled = false
cfg.Upstream.AutoRefresh = false
cfg.Upstream.RefreshRetries = 0
cfg.Upstream.CrossSiteFailover = false
// 所有站点都指向假站点,避免 failover 打到公网
for _, st := range cfg.Sites {
st.BaseURL = hang.URL
st.AJAXURL = hang.URL + "/wp-admin/admin-ajax.php"
}
var modelID string
for id, m := range cfg.Models {
if m.Enabled {
modelID = id
break
}
}
if modelID == "" {
t.Fatalf("example config 里没有可用模型")
}
s := newTestServerWithConfig(t, cfg)
body := `{"model":"` + modelID + `","messages":[{"role":"user","content":"hi"}],"stream":true}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
w := httptest.NewRecorder()
done := make(chan struct{})
go func() {
defer close(done)
s.handleChat(w, req)
}()
var max int64
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
loop:
for {
if v := s.met.InFlight(); v > max {
max = v
}
select {
case <-done:
break loop
case <-ticker.C:
}
}
<-done
if max > 1 {
t.Fatalf("一次流式请求的 in_flight 峰值 = %d, want 1", max)
}
if max == 0 {
t.Fatalf("请求期间 in_flight 一直是 0,测试没抓到窗口")
}
}