Files
user-system/internal/api/handler/api_contract_test.go
long-agent 5ca3633be4 feat: 系统全面优化 - 设备管理/登录日志导出/性能监控/设置页面
后端:
- 新增全局设备管理 API(DeviceHandler.GetAllDevices)
- 新增登录日志导出功能(LogHandler.ExportLoginLogs, CSV/XLSX)
- 新增设置服务(SettingsService)和设置页面 API
- 设备管理支持多条件筛选(状态/信任状态/关键词)
- 登录日志支持流式导出防 OOM
- 操作日志支持按方法/时间范围搜索
- 主题配置服务(ThemeService)
- 增强监控健康检查(Prometheus metrics + SLO)
- 移除旧 ratelimit.go(已迁移至 robustness)
- 修复 SocialAccount NULL 扫描问题
- 新增 API 契约测试、Handler 测试、Settings 测试

前端:
- 新增管理员设备管理页面(DevicesPage)
- 新增管理员登录日志导出功能
- 新增系统设置页面(SettingsPage)
- 设备管理支持筛选和分页
- 增强 HTTP 响应类型

测试:
- 业务逻辑测试 68 个(含并发 CONC_001~003)
- 规模测试 16 个(P99 百分位统计)
- E2E 测试、集成测试、契约测试
- 性能基准测试、鲁棒性测试

全面测试通过(38 个测试包)
2026-04-07 12:08:16 +08:00

424 lines
12 KiB
Go

package handler_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// =============================================================================
// API Contract Validation Tests
// These tests verify that API endpoints return correct response shapes
// =============================================================================
func TestAPIContractAuthLogin(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
tests := []struct {
name string
requestBody map[string]interface{}
expectedStatus int
checkResponse func(*testing.T, *http.Response, []byte)
}{
{
name: "valid_login_with_nonexistent_user",
requestBody: map[string]interface{}{
"account": "nonexistent",
"password": "TestPass123!",
},
expectedStatus: http.StatusUnauthorized, // or 500 if error handling differs
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
// Response should be parseable JSON
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Logf("Response body: %s", string(body))
}
},
},
{
name: "missing_account",
requestBody: map[string]interface{}{
"password": "TestPass123!",
},
expectedStatus: http.StatusBadRequest,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
// Should return valid JSON error response
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Response should be valid JSON: %v", err)
}
},
},
{
name: "empty_body",
requestBody: map[string]interface{}{},
expectedStatus: http.StatusBadRequest,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
// Empty body should still return valid JSON error
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Response should be valid JSON even on error: %v", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.requestBody)
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.expectedStatus {
t.Logf("Status = %d, want %d (body: %s)", resp.StatusCode, tt.expectedStatus, string(body))
}
respBody, _ := io.ReadAll(resp.Body)
tt.checkResponse(t, resp, respBody)
})
}
}
func TestAPIContractAuthRegister(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
tests := []struct {
name string
requestBody map[string]interface{}
expectedStatus int
checkResponse func(*testing.T, *http.Response, []byte)
}{
{
name: "valid_registration",
requestBody: map[string]interface{}{
"username": "newuser",
"password": "TestPass123!",
},
expectedStatus: http.StatusCreated,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Response is not valid JSON: %v", err)
}
// Should have user info
if _, ok := result["id"]; !ok {
t.Logf("Response does not have 'id' field: %+v", result)
}
},
},
{
name: "missing_username",
requestBody: map[string]interface{}{
"password": "TestPass123!",
},
expectedStatus: http.StatusBadRequest,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Response is not valid JSON: %v", err)
}
},
},
{
name: "missing_password",
requestBody: map[string]interface{}{
"username": "testuser",
},
expectedStatus: http.StatusBadRequest,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Response is not valid JSON: %v", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.requestBody)
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.expectedStatus {
t.Logf("Status = %d, want %d (body: %s)", resp.StatusCode, tt.expectedStatus, string(body))
}
respBody, _ := io.ReadAll(resp.Body)
tt.checkResponse(t, resp, respBody)
})
}
}
func TestAPIContractUserList(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
tests := []struct {
name string
queryParams string
expectedStatus int
checkResponse func(*testing.T, *http.Response, []byte)
}{
{
name: "unauthorized_without_token",
queryParams: "",
expectedStatus: http.StatusUnauthorized,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
// Should return some error response
t.Logf("Unauthorized response: status=%d body=%s", resp.StatusCode, string(body))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := server.URL + "/api/v1/users"
if tt.queryParams != "" {
url += "?" + tt.queryParams
}
req, _ := http.NewRequest("GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.expectedStatus {
t.Errorf("Status = %d, want %d", resp.StatusCode, tt.expectedStatus)
}
respBody, _ := io.ReadAll(resp.Body)
tt.checkResponse(t, resp, respBody)
})
}
}
func TestAPIContractHealthEndpoint(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
tests := []struct {
name string
path string
expectedStatus int
checkResponse func(*testing.T, *http.Response, []byte)
}{
{
name: "health_check",
path: "/health",
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
// Health endpoint should return status 200
t.Logf("Health response: status=%d body=%s", resp.StatusCode, string(body))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", server.URL+tt.path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.expectedStatus {
t.Errorf("Status = %d, want %d", resp.StatusCode, tt.expectedStatus)
}
respBody, _ := io.ReadAll(resp.Body)
tt.checkResponse(t, resp, respBody)
})
}
}
func TestAPIResponseContentType(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
// Test that API responses have correct Content-Type
t.Run("json_content_type", func(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{"username": "test", "password": "Test123!"})
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
t.Error("Content-Type header should be set")
}
if !strings.Contains(contentType, "application/json") {
t.Logf("Content-Type: %s", contentType)
}
})
}
func TestAPIErrorResponseShape(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
// Test error response structure consistency
t.Run("error_responses_are_parseable", func(t *testing.T) {
endpoints := []struct {
method string
path string
body map[string]interface{}
}{
{"POST", "/api/v1/auth/register", map[string]interface{}{}},
{"POST", "/api/v1/auth/login", map[string]interface{}{}},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
body, _ := json.Marshal(ep.body)
req, _ := http.NewRequest(ep.method, server.URL+ep.path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
// Only check error responses (4xx/5xx)
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
return
}
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
t.Logf("Non-JSON error response: %s", string(respBody))
} else {
t.Logf("Error response: %+v", result)
}
})
}
})
}
// =============================================================================
// Response Structure Tests for Success Cases
// =============================================================================
func TestAPIResponseSuccessStructure(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
if server == nil {
t.Skip("Server setup failed")
}
// Create a user first
regBody, _ := json.Marshal(map[string]interface{}{
"username": "contractuser",
"password": "TestPass123!",
})
regReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(regBody))
regReq.Header.Set("Content-Type", "application/json")
regResp, _ := http.DefaultClient.Do(regReq)
io.ReadAll(regResp.Body)
regResp.Body.Close()
// Login to get token
loginBody, _ := json.Marshal(map[string]interface{}{
"account": "contractuser",
"password": "TestPass123!",
})
loginReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginResp, err := http.DefaultClient.Do(loginReq)
if err != nil {
t.Fatalf("Login failed: %v", err)
}
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
accessToken, ok := loginResult["access_token"].(string)
if !ok {
t.Skip("Could not get access token")
}
t.Run("user_info_response", func(t *testing.T) {
req, _ := http.NewRequest("GET", server.URL+"/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Skipf("User info endpoint returned %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Response should be valid JSON: %v", err)
}
// Log the structure
t.Logf("User info response: %+v", result)
// Verify standard user info fields
requiredFields := []string{"id", "username", "status"}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf("Response should have '%s' field", field)
}
}
})
}