244 lines
8.3 KiB
Go
244 lines
8.3 KiB
Go
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)
|
||
}
|
||
}
|