feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
843
internal/integration/e2e_gateway_test.go
Normal file
843
internal/integration/e2e_gateway_test.go
Normal file
@@ -0,0 +1,843 @@
|
||||
//go:build e2e
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
baseURL = getEnv("BASE_URL", "http://localhost:8080")
|
||||
// ENDPOINT_PREFIX: 端点前缀,支持混合模式和非混合模式测试
|
||||
// - "" (默认): 使用 /v1/messages, /v1beta/models(混合模式,可调度 antigravity 账户)
|
||||
// - "/antigravity": 使用 /antigravity/v1/messages, /antigravity/v1beta/models(非混合模式,仅 antigravity 账户)
|
||||
endpointPrefix = getEnv("ENDPOINT_PREFIX", "")
|
||||
testInterval = 1 * time.Second // 测试间隔,防止限流
|
||||
)
|
||||
|
||||
const (
|
||||
// 注意:E2E 测试请使用环境变量注入密钥,避免任何凭证进入仓库历史。
|
||||
// 例如:
|
||||
// export CLAUDE_API_KEY="sk-..."
|
||||
// export GEMINI_API_KEY="sk-..."
|
||||
claudeAPIKeyEnv = "CLAUDE_API_KEY"
|
||||
geminiAPIKeyEnv = "GEMINI_API_KEY"
|
||||
)
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// Claude 模型列表
|
||||
var claudeModels = []string{
|
||||
// Opus 系列
|
||||
"claude-opus-4-5-thinking", // 直接支持
|
||||
"claude-opus-4", // 映射到 claude-opus-4-5-thinking
|
||||
"claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking
|
||||
// Sonnet 系列
|
||||
"claude-sonnet-4-5", // 直接支持
|
||||
"claude-sonnet-4-5-thinking", // 直接支持
|
||||
"claude-sonnet-4-5-20250929", // 映射到 claude-sonnet-4-5-thinking
|
||||
"claude-3-5-sonnet-20241022", // 映射到 claude-sonnet-4-5
|
||||
// Haiku 系列(映射到 gemini-3-flash)
|
||||
"claude-haiku-4",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-3-haiku-20240307",
|
||||
}
|
||||
|
||||
// Gemini 模型列表
|
||||
var geminiModels = []string{
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3-flash",
|
||||
"gemini-3-pro-low",
|
||||
"gemini-3-pro-high",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
mode := "混合模式"
|
||||
if endpointPrefix != "" {
|
||||
mode = "Antigravity 模式"
|
||||
}
|
||||
claudeKeySet := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv)) != ""
|
||||
geminiKeySet := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv)) != ""
|
||||
fmt.Printf("\n🚀 E2E Gateway Tests - %s (prefix=%q, %s, %s=%v, %s=%v)\n\n",
|
||||
baseURL,
|
||||
endpointPrefix,
|
||||
mode,
|
||||
claudeAPIKeyEnv,
|
||||
claudeKeySet,
|
||||
geminiAPIKeyEnv,
|
||||
geminiKeySet,
|
||||
)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func requireClaudeAPIKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv))
|
||||
if key == "" {
|
||||
t.Skipf("未设置 %s,跳过 Claude 相关 E2E 测试", claudeAPIKeyEnv)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func requireGeminiAPIKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv))
|
||||
if key == "" {
|
||||
t.Skipf("未设置 %s,跳过 Gemini 相关 E2E 测试", geminiAPIKeyEnv)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// TestClaudeModelsList 测试 GET /v1/models
|
||||
func TestClaudeModelsList(t *testing.T) {
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
url := baseURL + endpointPrefix + "/v1/models"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+claudeKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result["object"] != "list" {
|
||||
t.Errorf("期望 object=list, 得到 %v", result["object"])
|
||||
}
|
||||
|
||||
data, ok := result["data"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("响应缺少 data 数组")
|
||||
}
|
||||
t.Logf("✅ 返回 %d 个模型", len(data))
|
||||
}
|
||||
|
||||
// TestGeminiModelsList 测试 GET /v1beta/models
|
||||
func TestGeminiModelsList(t *testing.T) {
|
||||
geminiKey := requireGeminiAPIKey(t)
|
||||
url := baseURL + endpointPrefix + "/v1beta/models"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+geminiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
models, ok := result["models"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("响应缺少 models 数组")
|
||||
}
|
||||
t.Logf("✅ 返回 %d 个模型", len(models))
|
||||
}
|
||||
|
||||
// TestClaudeMessages 测试 Claude /v1/messages 接口
|
||||
func TestClaudeMessages(t *testing.T) {
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
for i, model := range claudeModels {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_非流式", func(t *testing.T) {
|
||||
testClaudeMessage(t, claudeKey, model, false)
|
||||
})
|
||||
time.Sleep(testInterval)
|
||||
t.Run(model+"_流式", func(t *testing.T) {
|
||||
testClaudeMessage(t, claudeKey, model, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testClaudeMessage(t *testing.T, claudeKey string, model string, stream bool) {
|
||||
url := baseURL + endpointPrefix + "/v1/messages"
|
||||
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 50,
|
||||
"stream": stream,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "Say 'hello' in one word."},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+claudeKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if stream {
|
||||
// 流式:读取 SSE 事件
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
eventCount := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
eventCount++
|
||||
if eventCount >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if eventCount == 0 {
|
||||
t.Fatal("未收到任何 SSE 事件")
|
||||
}
|
||||
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
|
||||
} else {
|
||||
// 非流式:解析 JSON 响应
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("期望 type=message, 得到 %v", result["type"])
|
||||
}
|
||||
t.Logf("✅ 收到消息响应 id=%v", result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiGenerateContent 测试 Gemini /v1beta/models/:model 接口
|
||||
func TestGeminiGenerateContent(t *testing.T) {
|
||||
geminiKey := requireGeminiAPIKey(t)
|
||||
for i, model := range geminiModels {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_非流式", func(t *testing.T) {
|
||||
testGeminiGenerate(t, geminiKey, model, false)
|
||||
})
|
||||
time.Sleep(testInterval)
|
||||
t.Run(model+"_流式", func(t *testing.T) {
|
||||
testGeminiGenerate(t, geminiKey, model, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGeminiGenerate(t *testing.T, geminiKey string, model string, stream bool) {
|
||||
action := "generateContent"
|
||||
if stream {
|
||||
action = "streamGenerateContent"
|
||||
}
|
||||
url := fmt.Sprintf("%s%s/v1beta/models/%s:%s", baseURL, endpointPrefix, model, action)
|
||||
if stream {
|
||||
url += "?alt=sse"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{
|
||||
{"text": "Say 'hello' in one word."},
|
||||
},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]int{
|
||||
"maxOutputTokens": 50,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+geminiKey)
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if stream {
|
||||
// 流式:读取 SSE 事件
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
eventCount := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
eventCount++
|
||||
if eventCount >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if eventCount == 0 {
|
||||
t.Fatal("未收到任何 SSE 事件")
|
||||
}
|
||||
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
|
||||
} else {
|
||||
// 非流式:解析 JSON 响应
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if _, ok := result["candidates"]; !ok {
|
||||
t.Error("响应缺少 candidates 字段")
|
||||
}
|
||||
t.Log("✅ 收到 candidates 响应")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeMessagesWithComplexTools 测试带复杂工具 schema 的请求
|
||||
// 模拟 Claude Code 发送的请求,包含需要清理的 JSON Schema 字段
|
||||
func TestClaudeMessagesWithComplexTools(t *testing.T) {
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
// 测试模型列表(只测试几个代表性模型)
|
||||
models := []string{
|
||||
"claude-opus-4-5-20251101", // Claude 模型
|
||||
"claude-haiku-4-5-20251001", // 映射到 Gemini
|
||||
}
|
||||
|
||||
for i, model := range models {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_复杂工具", func(t *testing.T) {
|
||||
testClaudeMessageWithTools(t, claudeKey, model)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testClaudeMessageWithTools(t *testing.T, claudeKey string, model string) {
|
||||
url := baseURL + endpointPrefix + "/v1/messages"
|
||||
|
||||
// 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具)
|
||||
// 这些字段需要被 cleanJSONSchema 清理
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read file contents",
|
||||
"input_schema": map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File path",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\x00]+$",
|
||||
},
|
||||
"encoding": map[string]any{
|
||||
"type": []string{"string", "null"},
|
||||
"default": "utf-8",
|
||||
"enum": []string{"utf-8", "ascii", "latin-1"},
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write content to file",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"maxLength": 1048576,
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
"additionalProperties": false,
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_files",
|
||||
"description": "List files in directory",
|
||||
"input_schema": map[string]any{
|
||||
"$id": "https://example.com/list-files.schema.json",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"directory": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"patterns": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"uniqueItems": true,
|
||||
},
|
||||
"recursive": map[string]any{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
},
|
||||
},
|
||||
"required": []string{"directory"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_code",
|
||||
"description": "Search code in files",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"format": "regex",
|
||||
},
|
||||
"max_results": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"exclusiveMinimum": 0,
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
"additionalProperties": false,
|
||||
"examples": []map[string]any{
|
||||
{"query": "function.*test", "max_results": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
// 测试 required 引用不存在的属性(应被自动过滤)
|
||||
{
|
||||
"name": "invalid_required_tool",
|
||||
"description": "Tool with invalid required field",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
// "nonexistent_field" 不存在于 properties 中,应被过滤掉
|
||||
"required": []string{"name", "nonexistent_field"},
|
||||
},
|
||||
},
|
||||
// 测试没有 properties 的 schema(应自动添加空 properties)
|
||||
{
|
||||
"name": "no_properties_tool",
|
||||
"description": "Tool without properties",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"should_be_removed"},
|
||||
},
|
||||
},
|
||||
// 测试没有 type 的 schema(应自动添加 type: OBJECT)
|
||||
{
|
||||
"name": "no_type_tool",
|
||||
"description": "Tool without type",
|
||||
"input_schema": map[string]any{
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 100,
|
||||
"stream": false,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "List files in the current directory"},
|
||||
},
|
||||
"tools": tools,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+claudeKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 400 错误说明 schema 清理不完整
|
||||
if resp.StatusCode == 400 {
|
||||
t.Fatalf("Schema 清理失败,收到 400 错误: %s", string(respBody))
|
||||
}
|
||||
|
||||
// 503 可能是账号限流,不算测试失败
|
||||
if resp.StatusCode == 503 {
|
||||
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
|
||||
}
|
||||
|
||||
// 429 是限流
|
||||
if resp.StatusCode == 429 {
|
||||
t.Skipf("请求被限流 (429): %s", string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("期望 type=message, 得到 %v", result["type"])
|
||||
}
|
||||
t.Logf("✅ 复杂工具 schema 测试通过, id=%v", result["id"])
|
||||
}
|
||||
|
||||
// TestClaudeMessagesWithThinkingAndTools 测试 thinking 模式下带工具调用的场景
|
||||
// 验证:当历史 assistant 消息包含 tool_use 但没有 signature 时,
|
||||
// 系统应自动添加 dummy thought_signature 避免 Gemini 400 错误
|
||||
func TestClaudeMessagesWithThinkingAndTools(t *testing.T) {
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
models := []string{
|
||||
"claude-haiku-4-5-20251001", // gemini-3-flash
|
||||
}
|
||||
for i, model := range models {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_thinking模式工具调用", func(t *testing.T) {
|
||||
testClaudeThinkingWithToolHistory(t, claudeKey, model)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testClaudeThinkingWithToolHistory(t *testing.T, claudeKey string, model string) {
|
||||
url := baseURL + endpointPrefix + "/v1/messages"
|
||||
|
||||
// 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话
|
||||
// 注意:tool_use 块故意不包含 signature,测试系统是否能正确添加 dummy signature
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 200,
|
||||
"stream": false,
|
||||
// 开启 thinking 模式
|
||||
"thinking": map[string]any{
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1024,
|
||||
},
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "List files in the current directory",
|
||||
},
|
||||
// assistant 消息包含 tool_use 但没有 signature
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I'll list the files for you.",
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01XGmNv",
|
||||
"name": "Bash",
|
||||
"input": map[string]any{"command": "ls -la"},
|
||||
// 故意不包含 signature
|
||||
},
|
||||
},
|
||||
},
|
||||
// 工具结果
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01XGmNv",
|
||||
"content": "file1.txt\nfile2.txt\ndir1/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tools": []map[string]any{
|
||||
{
|
||||
"name": "Bash",
|
||||
"description": "Execute bash commands",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+claudeKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 400 错误说明 thought_signature 处理失败
|
||||
if resp.StatusCode == 400 {
|
||||
t.Fatalf("thought_signature 处理失败,收到 400 错误: %s", string(respBody))
|
||||
}
|
||||
|
||||
// 503 可能是账号限流,不算测试失败
|
||||
if resp.StatusCode == 503 {
|
||||
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
|
||||
}
|
||||
|
||||
// 429 是限流
|
||||
if resp.StatusCode == 429 {
|
||||
t.Skipf("请求被限流 (429): %s", string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("期望 type=message, 得到 %v", result["type"])
|
||||
}
|
||||
t.Logf("✅ thinking 模式工具调用测试通过, id=%v", result["id"])
|
||||
}
|
||||
|
||||
// TestClaudeMessagesWithGeminiModel 测试在 Claude 端点使用 Gemini 模型
|
||||
// 验证:通过 /v1/messages 端点传入 gemini 模型名的场景(含前缀映射)
|
||||
// 仅在 Antigravity 模式下运行(ENDPOINT_PREFIX="/antigravity")
|
||||
func TestClaudeMessagesWithGeminiModel(t *testing.T) {
|
||||
if endpointPrefix != "/antigravity" {
|
||||
t.Skip("仅在 Antigravity 模式下运行")
|
||||
}
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
|
||||
// 测试通过 Claude 端点调用 Gemini 模型
|
||||
geminiViaClaude := []string{
|
||||
"gemini-3-flash", // 直接支持
|
||||
"gemini-3-pro-low", // 直接支持
|
||||
"gemini-3-pro-high", // 直接支持
|
||||
"gemini-3-pro", // 前缀映射 -> gemini-3-pro-high
|
||||
"gemini-3-pro-preview", // 前缀映射 -> gemini-3-pro-high
|
||||
}
|
||||
|
||||
for i, model := range geminiViaClaude {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_通过Claude端点", func(t *testing.T) {
|
||||
testClaudeMessage(t, claudeKey, model, false)
|
||||
})
|
||||
time.Sleep(testInterval)
|
||||
t.Run(model+"_通过Claude端点_流式", func(t *testing.T) {
|
||||
testClaudeMessage(t, claudeKey, model, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeMessagesWithNoSignature 测试历史 thinking block 不带 signature 的场景
|
||||
// 验证:Gemini 模型接受没有 signature 的 thinking block
|
||||
func TestClaudeMessagesWithNoSignature(t *testing.T) {
|
||||
claudeKey := requireClaudeAPIKey(t)
|
||||
models := []string{
|
||||
"claude-haiku-4-5-20251001", // gemini-3-flash - 支持无 signature
|
||||
}
|
||||
for i, model := range models {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_无signature", func(t *testing.T) {
|
||||
testClaudeWithNoSignature(t, claudeKey, model)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testClaudeWithNoSignature(t *testing.T, claudeKey string, model string) {
|
||||
url := baseURL + endpointPrefix + "/v1/messages"
|
||||
|
||||
// 模拟历史对话包含 thinking block 但没有 signature
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 200,
|
||||
"stream": false,
|
||||
// 开启 thinking 模式
|
||||
"thinking": map[string]any{
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1024,
|
||||
},
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What is 2+2?",
|
||||
},
|
||||
// assistant 消息包含 thinking block 但没有 signature
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me calculate 2+2...",
|
||||
// 故意不包含 signature
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "2+2 equals 4.",
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What is 3+3?",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+claudeKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
t.Fatalf("无 signature thinking 处理失败,收到 400 错误: %s", string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode == 503 {
|
||||
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
t.Skipf("请求被限流 (429): %s", string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("期望 type=message, 得到 %v", result["type"])
|
||||
}
|
||||
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
|
||||
}
|
||||
|
||||
// TestGeminiEndpointWithClaudeModel 测试通过 Gemini 端点调用 Claude 模型
|
||||
// 仅在 Antigravity 模式下运行(ENDPOINT_PREFIX="/antigravity")
|
||||
func TestGeminiEndpointWithClaudeModel(t *testing.T) {
|
||||
if endpointPrefix != "/antigravity" {
|
||||
t.Skip("仅在 Antigravity 模式下运行")
|
||||
}
|
||||
geminiKey := requireGeminiAPIKey(t)
|
||||
|
||||
// 测试通过 Gemini 端点调用 Claude 模型
|
||||
claudeViaGemini := []string{
|
||||
"claude-sonnet-4-5",
|
||||
"claude-opus-4-5-thinking",
|
||||
}
|
||||
|
||||
for i, model := range claudeViaGemini {
|
||||
if i > 0 {
|
||||
time.Sleep(testInterval)
|
||||
}
|
||||
t.Run(model+"_通过Gemini端点", func(t *testing.T) {
|
||||
testGeminiGenerate(t, geminiKey, model, false)
|
||||
})
|
||||
time.Sleep(testInterval)
|
||||
t.Run(model+"_通过Gemini端点_流式", func(t *testing.T) {
|
||||
testGeminiGenerate(t, geminiKey, model, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
48
internal/integration/e2e_helpers_test.go
Normal file
48
internal/integration/e2e_helpers_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build e2e
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// E2E Mock 模式支持
|
||||
// =============================================================================
|
||||
// 当 E2E_MOCK=true 时,使用本地 Mock 响应替代真实 API 调用。
|
||||
// 这允许在没有真实 API Key 的环境(如 CI)中验证基本的请求/响应流程。
|
||||
|
||||
// isMockMode 检查是否启用 Mock 模式
|
||||
func isMockMode() bool {
|
||||
return strings.EqualFold(os.Getenv("E2E_MOCK"), "true")
|
||||
}
|
||||
|
||||
// skipIfNoRealAPI 如果未配置真实 API Key 且不在 Mock 模式,则跳过测试
|
||||
func skipIfNoRealAPI(t *testing.T) {
|
||||
t.Helper()
|
||||
if isMockMode() {
|
||||
return // Mock 模式下不跳过
|
||||
}
|
||||
claudeKey := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv))
|
||||
geminiKey := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv))
|
||||
if claudeKey == "" && geminiKey == "" {
|
||||
t.Skip("未设置 API Key 且未启用 Mock 模式,跳过测试")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Key 脱敏(Task 6.10)
|
||||
// =============================================================================
|
||||
|
||||
// safeLogKey 安全地记录 API Key(仅显示前 8 位)
|
||||
func safeLogKey(t *testing.T, prefix string, key string) {
|
||||
t.Helper()
|
||||
key = strings.TrimSpace(key)
|
||||
if len(key) <= 8 {
|
||||
t.Logf("%s: ***(长度: %d)", prefix, len(key))
|
||||
return
|
||||
}
|
||||
t.Logf("%s: %s...(长度: %d)", prefix, key[:8], len(key))
|
||||
}
|
||||
317
internal/integration/e2e_user_flow_test.go
Normal file
317
internal/integration/e2e_user_flow_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
//go:build e2e
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// E2E 用户流程测试
|
||||
// 测试完整的用户操作链路:注册 → 登录 → 创建 API Key → 调用网关 → 查询用量
|
||||
|
||||
var (
|
||||
testUserEmail = "e2e-test-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + "@test.local"
|
||||
testUserPassword = "E2eTest@12345"
|
||||
testUserName = "e2e-test-user"
|
||||
)
|
||||
|
||||
// TestUserRegistrationAndLogin 测试用户注册和登录流程
|
||||
func TestUserRegistrationAndLogin(t *testing.T) {
|
||||
// 步骤 1: 注册新用户
|
||||
t.Run("注册新用户", func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"email": testUserEmail,
|
||||
"password": testUserPassword,
|
||||
"username": testUserName,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
resp, err := doRequest(t, "POST", "/api/auth/register", body, "")
|
||||
if err != nil {
|
||||
t.Skipf("注册接口不可用,跳过用户流程测试: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 注册可能返回 200(成功)或 400(邮箱已存在)或 403(注册已关闭)
|
||||
switch resp.StatusCode {
|
||||
case 200:
|
||||
t.Logf("✅ 用户注册成功: %s", testUserEmail)
|
||||
case 400:
|
||||
t.Logf("⚠️ 用户可能已存在: %s", string(respBody))
|
||||
case 403:
|
||||
t.Skipf("注册功能已关闭: %s", string(respBody))
|
||||
default:
|
||||
t.Logf("⚠️ 注册返回 HTTP %d: %s(继续尝试登录)", resp.StatusCode, string(respBody))
|
||||
}
|
||||
})
|
||||
|
||||
// 步骤 2: 登录获取 JWT
|
||||
var accessToken string
|
||||
t.Run("用户登录获取JWT", func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"email": testUserEmail,
|
||||
"password": testUserPassword,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
resp, err := doRequest(t, "POST", "/api/auth/login", body, "")
|
||||
if err != nil {
|
||||
t.Fatalf("登录请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Skipf("登录失败 HTTP %d: %s(可能需要先注册用户)", resp.StatusCode, string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Fatalf("解析登录响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 尝试从标准响应格式获取 token
|
||||
if token, ok := result["access_token"].(string); ok && token != "" {
|
||||
accessToken = token
|
||||
} else if data, ok := result["data"].(map[string]any); ok {
|
||||
if token, ok := data["access_token"].(string); ok {
|
||||
accessToken = token
|
||||
}
|
||||
}
|
||||
|
||||
if accessToken == "" {
|
||||
t.Skipf("未获取到 access_token,响应: %s", string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 token 不为空且格式基本正确
|
||||
if len(accessToken) < 10 {
|
||||
t.Fatalf("access_token 格式异常: %s", accessToken)
|
||||
}
|
||||
|
||||
t.Logf("✅ 登录成功,获取 JWT(长度: %d)", len(accessToken))
|
||||
})
|
||||
|
||||
if accessToken == "" {
|
||||
t.Skip("未获取到 JWT,跳过后续测试")
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤 3: 使用 JWT 获取当前用户信息
|
||||
t.Run("获取当前用户信息", func(t *testing.T) {
|
||||
resp, err := doRequest(t, "GET", "/api/user/me", nil, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
t.Logf("✅ 成功获取用户信息")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIKeyLifecycle 测试 API Key 的创建和使用
|
||||
func TestAPIKeyLifecycle(t *testing.T) {
|
||||
// 先登录获取 JWT
|
||||
accessToken := loginTestUser(t)
|
||||
if accessToken == "" {
|
||||
t.Skip("无法登录,跳过 API Key 生命周期测试")
|
||||
return
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
|
||||
// 步骤 1: 创建 API Key
|
||||
t.Run("创建API_Key", func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"name": "e2e-test-key-" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
resp, err := doRequest(t, "POST", "/api/keys", body, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 API Key 请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Skipf("创建 API Key 失败 HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 从响应中提取 key
|
||||
if key, ok := result["key"].(string); ok {
|
||||
apiKey = key
|
||||
} else if data, ok := result["data"].(map[string]any); ok {
|
||||
if key, ok := data["key"].(string); ok {
|
||||
apiKey = key
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
t.Skipf("未获取到 API Key,响应: %s", string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 API Key 脱敏日志(只显示前 8 位)
|
||||
masked := apiKey
|
||||
if len(masked) > 8 {
|
||||
masked = masked[:8] + "..."
|
||||
}
|
||||
t.Logf("✅ API Key 创建成功: %s", masked)
|
||||
})
|
||||
|
||||
if apiKey == "" {
|
||||
t.Skip("未创建 API Key,跳过后续测试")
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤 2: 使用 API Key 调用网关(需要 Claude 或 Gemini 可用)
|
||||
t.Run("使用API_Key调用网关", func(t *testing.T) {
|
||||
// 尝试调用 models 列表(最轻量的 API 调用)
|
||||
resp, err := doRequest(t, "GET", "/v1/models", nil, apiKey)
|
||||
if err != nil {
|
||||
t.Fatalf("网关请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 可能返回 200(成功)或 402(余额不足)或 403(无可用账户)
|
||||
switch {
|
||||
case resp.StatusCode == 200:
|
||||
t.Logf("✅ API Key 网关调用成功")
|
||||
case resp.StatusCode == 402:
|
||||
t.Logf("⚠️ 余额不足,但 API Key 认证通过")
|
||||
case resp.StatusCode == 403:
|
||||
t.Logf("⚠️ 无可用账户,但 API Key 认证通过")
|
||||
default:
|
||||
t.Logf("⚠️ 网关返回 HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
})
|
||||
|
||||
// 步骤 3: 查询用量记录
|
||||
t.Run("查询用量记录", func(t *testing.T) {
|
||||
resp, err := doRequest(t, "GET", "/api/usage/dashboard", nil, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("用量查询请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("⚠️ 用量查询返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("✅ 用量查询成功")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 辅助函数
|
||||
// =============================================================================
|
||||
|
||||
func doRequest(t *testing.T, method, path string, body []byte, token string) (*http.Response, error) {
|
||||
t.Helper()
|
||||
|
||||
url := baseURL + path
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func loginTestUser(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// 先尝试用管理员账户登录
|
||||
adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local")
|
||||
adminPassword := getEnv("ADMIN_PASSWORD", "")
|
||||
|
||||
if adminPassword == "" {
|
||||
// 尝试用测试用户
|
||||
adminEmail = testUserEmail
|
||||
adminPassword = testUserPassword
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"email": adminEmail,
|
||||
"password": adminPassword,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
resp, err := doRequest(t, "POST", "/api/auth/login", body, "")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return ""
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if token, ok := result["access_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
if data, ok := result["data"].(map[string]any); ok {
|
||||
if token, ok := data["access_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// redactAPIKey API Key 脱敏,只显示前 8 位
|
||||
func redactAPIKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if len(key) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return key[:8] + "..."
|
||||
}
|
||||
222
internal/integration/integration_test.go
Normal file
222
internal/integration/integration_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite" // 纯 Go SQLite,注册 "sqlite" 驱动
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
)
|
||||
|
||||
var integDBCounter int64
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
id := atomic.AddInt64(&integDBCounter, 1)
|
||||
dsn := fmt.Sprintf("file:integtestdb%d?mode=memory&cache=private", id)
|
||||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||||
DriverName: "sqlite",
|
||||
DSN: dsn,
|
||||
}), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&domain.User{}, &domain.Role{}, &domain.Permission{}, &domain.Device{}); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func cleanupTestDB(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
// setupTestServer 测试服务器
|
||||
func setupTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/auth/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"code":0,"message":"success","data":{"user_id":1}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"test-token"}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"username":"testuser"}}`))
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
// TestDatabaseIntegration 测试数据库集成
|
||||
func TestDatabaseIntegration(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
repo := repository.NewUserRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("CreateUser", func(t *testing.T) {
|
||||
user := &domain.User{
|
||||
Phone: domain.StrPtr("13800138000"),
|
||||
Username: "integrationuser",
|
||||
Password: "hashedpassword",
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
t.Error("用户ID不应为0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FindUser", func(t *testing.T) {
|
||||
user, err := repo.GetByUsername(ctx, "integrationuser")
|
||||
if err != nil {
|
||||
t.Fatalf("查询用户失败: %v", err)
|
||||
}
|
||||
if domain.DerefStr(user.Phone) != "13800138000" {
|
||||
t.Errorf("Phone = %v, want 13800138000", domain.DerefStr(user.Phone))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateUser", func(t *testing.T) {
|
||||
user, _ := repo.GetByUsername(ctx, "integrationuser")
|
||||
user.Nickname = "已更新"
|
||||
if err := repo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("更新用户失败: %v", err)
|
||||
}
|
||||
found, _ := repo.GetByID(ctx, user.ID)
|
||||
if found.Nickname != "已更新" {
|
||||
t.Errorf("Nickname = %v, want 已更新", found.Nickname)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteUser", func(t *testing.T) {
|
||||
user, _ := repo.GetByUsername(ctx, "integrationuser")
|
||||
if err := repo.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("删除用户失败: %v", err)
|
||||
}
|
||||
_, err := repo.GetByUsername(ctx, "integrationuser")
|
||||
if err == nil {
|
||||
t.Error("删除后查询应返回错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTransactionIntegration 测试事务集成
|
||||
func TestTransactionIntegration(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
t.Run("TransactionRollback", func(t *testing.T) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
user := &domain.User{
|
||||
Phone: domain.StrPtr("13811111111"),
|
||||
Username: "txrollbackuser",
|
||||
Password: "hashedpassword",
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
if err := tx.Create(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("模拟错误,触发回滚")
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("事务应该失败")
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&domain.User{}).Where("username = ?", "txrollbackuser").Count(&count)
|
||||
if count > 0 {
|
||||
t.Error("事务回滚后用户不应存在")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TransactionCommit", func(t *testing.T) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
user := &domain.User{
|
||||
Phone: domain.StrPtr("13822222222"),
|
||||
Username: "txcommituser",
|
||||
Password: "hashedpassword",
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
return tx.Create(user).Error
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("事务失败: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&domain.User{}).Where("username = ?", "txcommituser").Count(&count)
|
||||
if count != 1 {
|
||||
t.Error("事务提交后用户应存在")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIIntegration 测试HTTP API集成
|
||||
func TestAPIIntegration(t *testing.T) {
|
||||
server := setupTestServer(t)
|
||||
defer server.Close()
|
||||
|
||||
t.Run("RegisterEndpoint", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/auth/register", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LoginEndpoint", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/auth/login", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetUserEndpoint", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/users/1")
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user