1
0
Files
nex/backend/tests/integration/conversion_test.go
lanyuanxiaoyao 4c6b49099d feat: 配置 golangci-lint 静态分析并修复存量违规
- 新增 backend/.golangci.yml 配置 12 个 linter(forbidigo、errorlint、errcheck、staticcheck、revive、gocritic、gosec、bodyclose、noctx、nilerr、goimports、gocyclo)
- 新增 lefthook.yml 配置 pre-commit hook 自动运行 lint
- 修复存量代码违规:errors.Is/As 替换、zap.Error 替换、import 排序、errcheck 修复
- 更新 README 补充编码规范说明
- 归档 backend-code-lint 变更
2026-04-24 13:01:48 +08:00

600 lines
20 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"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
"nex/backend/internal/handler"
"nex/backend/internal/handler/middleware"
"nex/backend/internal/provider"
"nex/backend/internal/repository"
"nex/backend/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/gorm"
openaiConv "nex/backend/internal/conversion/openai"
)
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)
_, err := w.Write([]byte(`{"error":"not mocked"}`))
require.NoError(t, err)
}))
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, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req map[string]any
require.NoError(t, 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")
require.NoError(t, 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
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "chat.completion", resp["object"])
choices, ok := resp["choices"].([]any)
require.True(t, ok)
require.Len(t, choices, 1)
choice, ok := choices[0].(map[string]any)
require.True(t, ok)
msg, ok := choice["message"].(map[string]any)
require.True(t, ok)
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, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req map[string]any
require.NoError(t, 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")
require.NoError(t, 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
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "message", resp["type"])
content, ok := resp["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
block, ok2 := content[0].(map[string]any)
require.True(t, ok2)
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, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req map[string]any
require.NoError(t, json.Unmarshal(body, &req))
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
assert.Equal(t, "gpt-4", req["model"])
w.Header().Set("Content-Type", "application/json")
// 上游返回上游模型名
_, err = 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}}`))
require.NoError(t, err)
})
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, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req map[string]any
require.NoError(t, json.Unmarshal(body, &req))
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
assert.Equal(t, "claude-3-opus", req["model"])
// 上游返回上游模型名
w.Header().Set("Content-Type", "application/json")
_, err = 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}}`))
require.NoError(t, err)
})
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 {
_, err := w.Write([]byte(e))
require.NoError(t, err)
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 {
_, err := w.Write([]byte(e))
require.NoError(t, err)
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
require.NoError(t, json.Unmarshal(anthropicBody, &anthropicResp))
data, okd := anthropicResp["data"].([]any)
require.True(t, okd)
assert.Len(t, data, 2)
first, okf := data[0].(map[string]any)
require.True(t, okf)
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
require.NoError(t, json.Unmarshal(openaiBody, &openaiResp))
oaiData, oki := openaiResp["data"].([]any)
require.True(t, oki)
assert.Len(t, oaiData, 1)
firstOai, okf2 := oaiData[0].(map[string]any)
require.True(t, okf2)
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
require.NoError(t, 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
require.NoError(t, 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
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
assert.Equal(t, "openai", created["protocol"])
}
// Suppress unused imports
var (
_ = fmt.Sprintf
_ = strings.Contains
_ = time.Second
)