1
0
Files
nex/backend/tests/integration/conversion_test.go
lanyuanxiaoyao 280099b89c refactor: 后端日志系统重构
- 新增模块化日志器(pkg/logger/module.go)
- 新增 GORM 日志适配器
- 统一日志入口,移除所有 zap.L() 全局 logger 调用
- 字段标准化
- 启动阶段使用结构化日志
- 更新所有相关测试
2026-04-23 18:37:51 +08:00

581 lines
19 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 integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/gorm"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
openaiConv "nex/backend/internal/conversion/openai"
"nex/backend/internal/handler"
"nex/backend/internal/handler/middleware"
"nex/backend/internal/provider"
"nex/backend/internal/repository"
"nex/backend/internal/service"
)
func init() {
gin.SetMode(gin.TestMode)
}
// setupConversionTest 创建包含 ConversionEngine 的完整测试环境
func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server) {
t.Helper()
// 创建 mock 上游服务器
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 默认返回成功,由各测试 case 覆盖
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"error":"not mocked"}`))
}))
db := setupTestDB(t)
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
upstream.Close()
})
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
statsRepo := repository.NewStatsRepository(db)
logger := zap.NewNop()
routingCache := service.NewRoutingCache(modelRepo, providerRepo, logger)
statsBuffer := service.NewStatsBuffer(statsRepo, logger)
providerService := service.NewProviderService(providerRepo, modelRepo, routingCache)
modelService := service.NewModelService(modelRepo, providerRepo, routingCache)
routingService := service.NewRoutingService(routingCache)
statsService := service.NewStatsService(statsRepo, statsBuffer)
// 创建 ConversionEngine
registry := conversion.NewMemoryRegistry()
require.NoError(t, registry.Register(openaiConv.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
engine := conversion.NewConversionEngine(registry, logger)
providerClient := provider.NewClient(logger)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, logger)
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
_ = modelService
r := gin.New()
r.Use(middleware.CORS())
// 代理路由
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
// 管理路由
providers := r.Group("/api/providers")
{
providers.GET("", providerHandler.ListProviders)
providers.POST("", providerHandler.CreateProvider)
providers.GET("/:id", providerHandler.GetProvider)
providers.PUT("/:id", providerHandler.UpdateProvider)
providers.DELETE("/:id", providerHandler.DeleteProvider)
}
models := r.Group("/api/models")
{
models.GET("", modelHandler.ListModels)
models.POST("", modelHandler.CreateModel)
models.GET("/:id", modelHandler.GetModel)
models.PUT("/:id", modelHandler.UpdateModel)
models.DELETE("/:id", modelHandler.DeleteModel)
}
_ = statsHandler
return r, db, upstream
}
// createProviderAndModel 辅助:创建供应商和模型
func createProviderAndModel(t *testing.T, r *gin.Engine, providerID, protocol, modelName string, upstreamURL string) {
t.Helper()
providerBody, _ := json.Marshal(map[string]string{
"id": providerID,
"name": providerID,
"api_key": "test-key",
"base_url": upstreamURL,
"protocol": protocol,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/providers", bytes.NewReader(providerBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, 201, w.Code)
modelBody, _ := json.Marshal(map[string]string{
"provider_id": providerID,
"model_name": modelName,
})
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/models", bytes.NewReader(modelBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, 201, w.Code)
}
// ============ 跨协议非流式转换测试 ============
func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
r, _, upstream := setupConversionTest(t)
// 配置上游返回 Anthropic 格式响应
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求被转换为 Anthropic 格式
body, _ := io.ReadAll(r.Body)
var req map[string]any
json.Unmarshal(body, &req)
assert.Equal(t, "/v1/messages", r.URL.Path)
assert.Contains(t, r.Header.Get("Content-Type"), "application/json")
// 返回 Anthropic 响应
resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "anthropic_p/claude-3-opus",
"content": []map[string]any{
{"type": "text", "text": "Hello from Anthropic!"},
},
"stop_reason": "end_turn",
"usage": map[string]any{
"input_tokens": 10,
"output_tokens": 20,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
// 使用 OpenAI 格式发送请求
openaiReq := map[string]any{
"model": "anthropic_p/claude-3-opus",
"messages": []map[string]any{
{"role": "user", "content": "Hello"},
},
"stream": false,
}
body, _ := json.Marshal(openaiReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "chat.completion", resp["object"])
choices := resp["choices"].([]any)
require.Len(t, choices, 1)
choice := choices[0].(map[string]any)
msg := choice["message"].(map[string]any)
assert.Contains(t, msg["content"], "Hello from Anthropic!")
}
func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) {
r, _, upstream := setupConversionTest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]any
json.Unmarshal(body, &req)
assert.Equal(t, "/chat/completions", r.URL.Path)
assert.Contains(t, r.Header.Get("Authorization"), "Bearer test-key")
resp := map[string]any{
"id": "chatcmpl-test",
"object": "chat.completion",
"model": "gpt-4",
"created": time.Now().Unix(),
"choices": []map[string]any{
{
"index": 0,
"message": map[string]any{"role": "assistant", "content": "Hello from OpenAI!"},
"finish_reason": "stop",
},
},
"usage": map[string]any{
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
anthropicReq := map[string]any{
"model": "openai_p/gpt-4",
"max_tokens": 1024,
"messages": []map[string]any{
{"role": "user", "content": "Hello"},
},
"stream": false,
}
body, _ := json.Marshal(anthropicReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "message", resp["type"])
content := resp["content"].([]any)
require.Len(t, content, 1)
block := content[0].(map[string]any)
assert.Contains(t, block["text"], "Hello from OpenAI!")
}
// ============ 同协议透传测试 ============
func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
r, _, upstream := setupConversionTest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/chat/completions", r.URL.Path)
body, _ := io.ReadAll(r.Body)
var req map[string]any
json.Unmarshal(body, &req)
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
assert.Equal(t, "gpt-4", req["model"])
w.Header().Set("Content-Type", "application/json")
// 上游返回上游模型名
w.Write([]byte(`{"id":"chatcmpl-pass","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"passthrough"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`))
})
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
reqBody := map[string]any{
"model": "openai_p/gpt-4", // 客户端发送统一 ID
"messages": []map[string]any{{"role": "user", "content": "test"}},
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
// Smart Passthrough: 响应体中的上游模型名应被改写为统一 ID
assert.Contains(t, w.Body.String(), `"model":"openai_p/gpt-4"`)
}
func TestConversion_AnthropicToAnthropic_Passthrough(t *testing.T) {
r, _, upstream := setupConversionTest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/messages", r.URL.Path)
body, _ := io.ReadAll(r.Body)
var req map[string]any
json.Unmarshal(body, &req)
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
assert.Equal(t, "claude-3-opus", req["model"])
// 上游返回上游模型名
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"msg-pass","type":"message","role":"assistant","model":"claude-3-opus","content":[{"type":"text","text":"passthrough"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":1}}`))
})
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
reqBody := map[string]any{
"model": "anthropic_p/claude-3-opus", // 客户端发送统一 ID
"max_tokens": 1024,
"messages": []map[string]any{{"role": "user", "content": "test"}},
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
// Smart Passthrough: 响应体中的上游模型名应被改写为统一 ID
assert.Contains(t, w.Body.String(), `"model":"anthropic_p/claude-3-opus"`)
}
// ============ 流式转换测试 ============
func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) {
r, _, upstream := setupConversionTest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
events := []string{
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"model\":\"claude-3-opus\",\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n",
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
}
for _, e := range events {
w.Write([]byte(e))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
})
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
openaiReq := map[string]any{
"model": "anthropic_p/claude-3-opus",
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
"stream": true,
}
body, _ := json.Marshal(openaiReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
ct := w.Header().Get("Content-Type")
assert.Contains(t, ct, "text/event-stream")
}
func TestConversion_AnthropicToOpenAI_Stream(t *testing.T) {
r, _, upstream := setupConversionTest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
events := []string{
fmt.Sprintf("data: {\"id\":\"chatcmpl-s\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n"),
"data: {\"id\":\"chatcmpl-s\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hey\"}}]}\n\n",
"data: {\"id\":\"chatcmpl-s\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n",
"data: {\"id\":\"chatcmpl-s\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
}
for _, e := range events {
w.Write([]byte(e))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
})
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
anthropicReq := map[string]any{
"model": "openai_p/gpt-4",
"max_tokens": 1024,
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
"stream": true,
}
body, _ := json.Marshal(anthropicReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
ct := w.Header().Get("Content-Type")
assert.Contains(t, ct, "text/event-stream")
}
// ============ Models 接口测试 ============
func TestConversion_Models_CrossProtocol(t *testing.T) {
// 测试 Models 接口跨协议转换的编解码逻辑
// 由于 GET /models 无 body 无法路由,此处测试 adapter 级别的编解码
registry := conversion.NewMemoryRegistry()
require.NoError(t, registry.Register(openaiConv.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
openaiAdapter, _ := registry.Get("openai")
anthropicAdapter, _ := registry.Get("anthropic")
// 模拟 OpenAI 格式的 models 响应
openaiModelsBody := []byte(`{"object":"list","data":[{"id":"gpt-4","object":"model","created":1700000000,"owned_by":"openai"},{"id":"gpt-3.5-turbo","object":"model","created":1700000001,"owned_by":"openai"}]}`)
// OpenAI decode → Canonical → Anthropic encode
modelList, err := openaiAdapter.DecodeModelsResponse(openaiModelsBody)
require.NoError(t, err)
assert.Len(t, modelList.Models, 2)
assert.Equal(t, "gpt-4", modelList.Models[0].ID)
// 编码为 Anthropic 格式
anthropicBody, err := anthropicAdapter.EncodeModelsResponse(modelList)
require.NoError(t, err)
var anthropicResp map[string]any
json.Unmarshal(anthropicBody, &anthropicResp)
data := anthropicResp["data"].([]any)
assert.Len(t, data, 2)
first := data[0].(map[string]any)
assert.Equal(t, "gpt-4", first["id"])
assert.Equal(t, "model", first["type"])
// 反向测试Anthropic decode → Canonical → OpenAI encode
anthropicModelsBody := []byte(`{"data":[{"id":"claude-3-opus","type":"model","display_name":"Claude 3 Opus","created_at":"2025-01-01T00:00:00Z"}],"has_more":false}`)
modelList2, err := anthropicAdapter.DecodeModelsResponse(anthropicModelsBody)
require.NoError(t, err)
assert.Len(t, modelList2.Models, 1)
assert.Equal(t, "Claude 3 Opus", modelList2.Models[0].Name)
openaiBody, err := openaiAdapter.EncodeModelsResponse(modelList2)
require.NoError(t, err)
var openaiResp map[string]any
json.Unmarshal(openaiBody, &err)
json.Unmarshal(openaiBody, &openaiResp)
oaiData := openaiResp["data"].([]any)
assert.Len(t, oaiData, 1)
firstOai := oaiData[0].(map[string]any)
assert.Equal(t, "claude-3-opus", firstOai["id"])
}
// ============ 错误响应测试 ============
func TestConversion_ErrorResponse_Format(t *testing.T) {
r, _, _ := setupConversionTest(t)
// 请求不存在的模型
reqBody := map[string]any{
"model": "nonexistent",
"messages": []map[string]any{{"role": "user", "content": "test"}},
}
body, _ := json.Marshal(reqBody)
// OpenAI 协议格式
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.True(t, w.Code >= 400)
}
// ============ 旧路由返回 404 ============
func TestConversion_OldRoutes_Return404(t *testing.T) {
r, _, _ := setupConversionTest(t)
// 旧 OpenAI 路由
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"test"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
// Gin 路由匹配但协议不支持返回 400
assert.Equal(t, 400, w.Code)
// 旧 Anthropic 路由
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"test"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
}
// ============ Provider Protocol 字段测试 ============
func TestConversion_ProviderWithProtocol(t *testing.T) {
r, _, _ := setupConversionTest(t)
// 创建带 protocol 字段的 provider
providerBody := map[string]any{
"id": "test_protocol",
"name": "Test Protocol",
"api_key": "sk-test",
"base_url": "https://test.com",
"protocol": "anthropic",
}
body, _ := json.Marshal(providerBody)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/providers", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, 201, w.Code)
var created map[string]any
json.Unmarshal(w.Body.Bytes(), &created)
assert.Equal(t, "sk-test", created["api_key"])
// 获取时应包含 protocol
w = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/api/providers/test_protocol", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var fetched map[string]any
json.Unmarshal(w.Body.Bytes(), &fetched)
assert.Equal(t, "anthropic", fetched["protocol"])
}
func TestConversion_ProviderDefaultProtocol(t *testing.T) {
r, _, _ := setupConversionTest(t)
// 不指定 protocol默认应为 openai
providerBody := map[string]any{
"id": "default_proto",
"name": "Default",
"api_key": "sk-test",
"base_url": "https://test.com",
}
body, _ := json.Marshal(providerBody)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/providers", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, 201, w.Code)
var created map[string]any
json.Unmarshal(w.Body.Bytes(), &created)
assert.Equal(t, "openai", created["protocol"])
}
// Suppress unused imports
var _ = fmt.Sprintf
var _ = strings.Contains
var _ = time.Second