Files
team-api/relay/common/debug_session_test.go
T

244 lines
8.3 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 common
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relaykit/types"
)
// 验证调试会话完整生命周期:
// 中间失败尝试 Submit 立即提交(is_final=false、段4为空),
// 最终尝试 MarkFinal 挂起、FinalizeAndSubmit 补段4后提交,且幂等。
func TestDebugSessionLifecycle(t *testing.T) {
var records []*DebugLogRecord
origHook := SubmitDebugLog
SubmitDebugLog = func(ctx context.Context, r *DebugLogRecord) { records = append(records, r) }
defer func() { SubmitDebugLog = origHook }()
s := NewDebugSession("req-1", 1, 2, 3, "/v1/chat/completions")
// 段1:客户端请求(含凭证头,应脱敏)
h := http.Header{}
h.Set("Authorization", "Bearer sk-test-key-12345678")
h.Set("Content-Type", "application/json")
s.CaptureClientRequest(h, []byte(`{"model":"m"}`))
selection := &ChannelSelection{ChannelID: 9, ChannelName: "ch9", ChannelType: 1, UpstreamModelName: "up-m"}
// 第一次尝试失败(中间):Submit 立即提交
a1 := s.BeginAttempt(selection, "m", "chat_completions", true, 0)
a1.Submit(errors.New("boom"))
if len(records) != 1 {
t.Fatalf("中间尝试应提交 1 条,实际 %d", len(records))
}
r1 := records[0]
if r1.IsFinal {
t.Error("中间尝试 is_final 应为 false")
}
if r1.Error != "boom" || r1.ChannelID != 9 || r1.RetryIndex != 0 || !r1.IsStream {
t.Errorf("中间尝试元数据错误: %+v", r1)
}
if string(r1.ClientReqBody) != `{"model":"m"}` {
t.Errorf("段1 请求体错误: %q", r1.ClientReqBody)
}
if r1.ClientReqHeaders["Authorization"] != "Bearer****5678" {
t.Errorf("段1 凭证头未脱敏: %q", r1.ClientReqHeaders["Authorization"])
}
if r1.ClientRespBody != nil {
t.Errorf("中间尝试段4应为空: %q", r1.ClientRespBody)
}
// 第二次尝试成功:MarkFinal → 模拟段2捕获 → FinalizeAndSubmit 补段4
a2 := s.BeginAttempt(selection, "m", "chat_completions", true, 1)
req, _ := http.NewRequest(http.MethodPost, "http://upstream/v1/chat", strings.NewReader("converted-body"))
a2.Capture.captureUpstreamRequest(req)
// 模拟传输层读取请求体 + 上游响应
_, _ = io.ReadAll(req.Body)
_ = req.Body.Close()
resp := &http.Response{StatusCode: 200, Header: http.Header{"X-Up": []string{"v"}}, Body: io.NopCloser(strings.NewReader("upstream-resp"))}
a2.Capture.captureUpstreamResponse(resp)
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
// 段4 writer
rec := httptest.NewRecorder()
dw := NewDebugClientWriter(rec)
s.SetClientWriter(dw)
dw.WriteHeader(200)
_, _ = dw.Write([]byte("client-resp"))
a2.MarkFinal(nil)
s.FinalizeAndSubmit(123, 45)
if len(records) != 2 {
t.Fatalf("最终尝试应提交第 2 条,实际 %d", len(records))
}
r2 := records[1]
if !r2.IsFinal || r2.RetryIndex != 1 || r2.Error != "" {
t.Errorf("最终尝试元数据错误: %+v", r2)
}
if string(r2.UpstreamReqBody) != "converted-body" {
t.Errorf("段2 请求体错误: %q", r2.UpstreamReqBody)
}
if r2.UpstreamStatusCode != 200 || r2.UpstreamRespHeaders["X-Up"] != "v" {
t.Errorf("段3 状态/响应头错误: %d %v", r2.UpstreamStatusCode, r2.UpstreamRespHeaders)
}
if string(r2.UpstreamRespBody) != "upstream-resp" {
t.Errorf("段3 响应体错误: %q", r2.UpstreamRespBody)
}
if string(r2.ClientRespBody) != "client-resp" || r2.ClientStatusCode != 200 {
t.Errorf("段4 错误: %q %d", r2.ClientRespBody, r2.ClientStatusCode)
}
if r2.TotalLatencyMs != 123 || r2.FirstTokenMs != 45 {
t.Errorf("耗时元数据错误: %d %d", r2.TotalLatencyMs, r2.FirstTokenMs)
}
// 幂等:重复 Finalize 不再提交
s.FinalizeAndSubmit(1, 1)
if len(records) != 2 {
t.Errorf("FinalizeAndSubmit 应幂等,实际提交 %d 条", len(records))
}
}
// nil 会话(开关未开启)调用 FinalizeAndSubmit 应安全 no-op
func TestDebugSessionNilSafe(t *testing.T) {
var s *DebugSession
s.FinalizeAndSubmit(0, 0) // 不应 panic
var a *DebugAttempt
a.Submit(errors.New("x")) // 不应 panic
a.MarkFinal(nil) // 不应 panic
}
// 任务版构造:ChannelMeta 字段映射、isStream 恒 false;
// MarkFinal + FinalizeAndSubmit 后提交带段4 的最终记录(任务提交单次尝试形态)
func TestBeginTaskAttempt(t *testing.T) {
var records []*DebugLogRecord
origHook := SubmitDebugLog
SubmitDebugLog = func(ctx context.Context, r *DebugLogRecord) { records = append(records, r) }
defer func() { SubmitDebugLog = origHook }()
s := NewDebugSession("req-task", 1, 2, 3, "/v1/videos")
meta := &ChannelMeta{ChannelID: 7, ChannelName: "ch7", ChannelType: int(constant.ProviderKling), UpstreamModelName: "kling-v2"}
a := s.BeginTaskAttempt(meta, "kling-v2-master", "videos", 0)
if a.isStream {
t.Error("任务尝试 isStream 应恒为 false")
}
// 段4 writer(任务提交响应,非流式 JSON)
rec := httptest.NewRecorder()
dw := NewDebugClientWriter(rec)
s.SetClientWriter(dw)
dw.WriteHeader(200)
_, _ = dw.Write([]byte(`{"id":"video_x"}`))
a.MarkFinal(nil)
s.FinalizeAndSubmit(88, 0)
if len(records) != 1 {
t.Fatalf("应提交 1 条记录,实际 %d", len(records))
}
r := records[0]
if r.ChannelID != 7 || r.ChannelName != "ch7" || r.ChannelType != int(constant.ProviderKling) {
t.Errorf("渠道元数据映射错误: %+v", r)
}
if r.UpstreamModel != "kling-v2" || r.ModelName != "kling-v2-master" || r.RelayMode != "videos" {
t.Errorf("模型/协议元数据错误: %+v", r)
}
if !r.IsFinal || r.IsStream {
t.Errorf("任务提交应为 is_final=true 且非流式: %+v", r)
}
if string(r.ClientRespBody) != `{"id":"video_x"}` || r.ClientStatusCode != 200 {
t.Errorf("段4 错误: %q %d", r.ClientRespBody, r.ClientStatusCode)
}
}
// 调试目标过滤:AND 组合,0 = 不限
func TestDebugTargetMatch(t *testing.T) {
s := ChannelSettings{DebugLogEnabled: true}
if !s.DebugTargetMatch(1, 2, 3) {
t.Error("未设过滤器应全部匹配")
}
s.DebugLogTenantID = 10
if !s.DebugTargetMatch(10, 99, 99) {
t.Error("匹配租户时应通过")
}
if s.DebugTargetMatch(11, 2, 3) {
t.Error("租户不匹配应拒绝")
}
s.DebugLogUserID = 20
if !s.DebugTargetMatch(10, 20, 99) {
t.Error("租户+成员均匹配应通过")
}
if s.DebugTargetMatch(10, 21, 99) {
t.Error("成员不匹配应拒绝")
}
s.DebugLogApiKeyID = 30
if !s.DebugTargetMatch(10, 20, 30) {
t.Error("三者均匹配应通过")
}
if s.DebugTargetMatch(10, 20, 31) {
t.Error("密钥不匹配应拒绝")
}
}
// 协议转换快照:常规转换按渠道类型推导上游协议;桥接标志优先;链路兜底两端
func TestCaptureProtocol(t *testing.T) {
s := NewDebugSession("req", 1, 2, 3, "/v1/chat/completions")
selection := &ChannelSelection{ChannelID: 9, ChannelType: int(constant.ProviderClaude)}
a := s.BeginAttempt(selection, "m", "chat_completions", false, 0)
// 客户端 openai → claude 渠道:上游协议 claude,链路兜底两端
info := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
ChannelMeta: &ChannelMeta{ChannelType: int(constant.ProviderClaude)},
}
info.AppendRequestConversion(types.RelayFormat(constant.RelayFormatOpenAI))
a.CaptureProtocol(info)
if a.conversion == nil {
t.Fatal("conversion 未快照")
}
if a.conversion.ClientFormat != "openai" || a.conversion.UpstreamFormat != "claude" {
t.Errorf("协议推导错误: %+v", a.conversion)
}
if a.conversion.Bridge != "" {
t.Errorf("常规转换不应有桥接标志: %q", a.conversion.Bridge)
}
// 直传:上游协议 = 客户端协议
info2 := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
ChannelMeta: &ChannelMeta{
ChannelType: int(constant.ProviderOpenAI),
Settings: ChannelSettings{PassThroughBodyEnabled: true},
},
}
a2 := s.BeginAttempt(selection, "m", "chat_completions", false, 1)
a2.CaptureProtocol(info2)
if a2.conversion.UpstreamFormat != "openai" || a2.conversion.Bridge != "pass_through" {
t.Errorf("直传推导错误: %+v", a2.conversion)
}
// Responses 桥接
info3 := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
UseResponsesAPI: true,
ChannelMeta: &ChannelMeta{ChannelType: int(constant.ProviderOpenAI)},
}
a3 := s.BeginAttempt(selection, "m", "chat_completions", false, 2)
a3.CaptureProtocol(info3)
if a3.conversion.UpstreamFormat != "responses" || a3.conversion.Bridge != "responses_api" {
t.Errorf("Responses 桥接推导错误: %+v", a3.conversion)
}
}