package integration import ( "encoding/json" "io" "net/http/httptest" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" ) // setupTestApp 创建用于测试的 Fiber 应用 func setupTestApp() *fiber.App { // 初始化日志器 _ = logger.InitLoggers( "debug", true, logger.LogRotationConfig{ Filename: "tests/integration/logs/error_handler_test.log", MaxSize: 10, MaxBackups: 3, MaxAge: 7, Compress: false, }, logger.LogRotationConfig{ Filename: "tests/integration/logs/access_test.log", MaxSize: 10, MaxBackups: 3, MaxAge: 7, Compress: false, }, ) appLogger := logger.GetAppLogger() app := fiber.New(fiber.Config{ ErrorHandler: errors.SafeErrorHandler(appLogger), }) // 注册中间件 app.Use(middleware.Recover(appLogger)) app.Use(requestid.New(requestid.Config{ Generator: func() string { return uuid.NewString() }, })) return app } // ErrorResponse 定义统一的错误响应结构 type ErrorResponse struct { Code interface{} `json:"code"` Data interface{} `json:"data"` Msg string `json:"msg"` Timestamp interface{} `json:"timestamp"` } // T029: 测试参数验证失败 -> 400 错误响应 func TestErrorHandler_ValidationError_Returns400(t *testing.T) { app := setupTestApp() // 创建测试路由:触发参数验证失败 app.Post("/api/test/validation", func(c *fiber.Ctx) error { // 模拟参数验证失败场景 return errors.New( errors.CodeInvalidParam, // 参数验证失败 "用户名长度必须在 3-20 个字符之间", ) }) // 发起请求 req := httptest.NewRequest("POST", "/api/test/validation", nil) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 400, resp.StatusCode, "参数验证失败应返回 400 状态码") // 解析响应 body body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err, "响应应为有效的 JSON 格式") // 验证响应字段 assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.Contains(t, errResp.Msg, "用户名长度", "错误消息应包含验证失败信息") assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空") // 验证响应头包含 Request ID requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ 验证失败测试通过 - Code: %s, Msg: %s, RequestID: %s", errResp.Code, errResp.Msg, requestID) } // T030: 测试资源未找到 -> 404 错误响应 func TestErrorHandler_ResourceNotFound_Returns404(t *testing.T) { app := setupTestApp() // 创建测试路由:触发资源未找到 app.Get("/api/test/users/:id", func(c *fiber.Ctx) error { userID := c.Params("id") // 模拟资源不存在场景 return errors.New( errors.CodeNotFound, // 资源不存在 "用户 ID "+userID+" 不存在", ) }) // 发起请求 req := httptest.NewRequest("GET", "/api/test/users/99999", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 404, resp.StatusCode, "资源未找到应返回 404 状态码") // 解析响应 body body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err, "响应应为有效的 JSON 格式") // 验证响应字段 assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.Contains(t, errResp.Msg, "不存在", "错误消息应包含资源不存在信息") assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空") // 验证响应头包含 Request ID requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ 资源未找到测试通过 - Code: %s, Msg: %s, RequestID: %s", errResp.Code, errResp.Msg, requestID) } // T031: 测试认证失败 -> 401 错误响应 func TestErrorHandler_AuthenticationFailed_Returns401(t *testing.T) { app := setupTestApp() // 创建测试路由:触发认证失败 app.Get("/api/test/protected", func(c *fiber.Ctx) error { // 模拟 Token 无效场景 return errors.New( errors.CodeUnauthorized, // 认证失败 "Token 已过期或无效", ) }) // 发起请求(无 Token) req := httptest.NewRequest("GET", "/api/test/protected", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 401, resp.StatusCode, "认证失败应返回 401 状态码") // 解析响应 body body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err, "响应应为有效的 JSON 格式") // 验证响应字段 assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.Contains(t, errResp.Msg, "Token", "错误消息应包含认证失败信息") assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空") // 验证响应头包含 Request ID requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ 认证失败测试通过 - Code: %s, Msg: %s, RequestID: %s", errResp.Code, errResp.Msg, requestID) } // T032: 验证所有错误响应格式一致性 func TestErrorHandler_ResponseFormatConsistency(t *testing.T) { app := setupTestApp() // 注册多种错误场景的测试路由 testCases := []struct { name string path string method string errorCode int errorMsg string expectedHTTP int }{ { name: "参数验证失败", path: "/api/test/validation-error", method: "POST", errorCode: errors.CodeInvalidParam, errorMsg: "参数验证失败", expectedHTTP: 400, }, { name: "数据格式错误", path: "/api/test/format-error", method: "POST", errorCode: errors.CodeInvalidParam, errorMsg: "JSON 格式错误", expectedHTTP: 400, }, { name: "资源不存在", path: "/api/test/not-found", method: "GET", errorCode: errors.CodeNotFound, errorMsg: "资源不存在", expectedHTTP: 404, }, { name: "认证失败", path: "/api/test/auth-error", method: "GET", errorCode: errors.CodeUnauthorized, errorMsg: "认证失败", expectedHTTP: 401, }, { name: "权限不足", path: "/api/test/permission-error", method: "GET", errorCode: errors.CodeForbidden, errorMsg: "权限不足", expectedHTTP: 403, }, { name: "数据库错误", path: "/api/test/db-error", method: "GET", errorCode: errors.CodeDatabaseError, errorMsg: "数据库连接失败", expectedHTTP: 500, }, { name: "外部服务错误", path: "/api/test/external-error", method: "POST", errorCode: errors.CodeServiceUnavailable, errorMsg: "外部 API 调用失败", expectedHTTP: 503, }, } // 为每个测试场景注册路由 for _, tc := range testCases { tc := tc // 捕获循环变量 switch tc.method { case "GET": app.Get(tc.path, func(c *fiber.Ctx) error { return errors.New(tc.errorCode, tc.errorMsg) }) case "POST": app.Post(tc.path, func(c *fiber.Ctx) error { return errors.New(tc.errorCode, tc.errorMsg) }) } } // 测试每个场景的响应格式 for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // 发起请求 req := httptest.NewRequest(tc.method, tc.path, nil) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, tc.expectedHTTP, resp.StatusCode, "%s 应返回 %d 状态码", tc.name, tc.expectedHTTP) // 解析响应 body body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err, "响应应为有效的 JSON 格式") // 验证响应字段完整性 assert.NotEmpty(t, errResp.Code, "响应必须包含 code 字段") assert.NotEmpty(t, errResp.Msg, "响应必须包含 msg 字段") assert.NotEmpty(t, errResp.Timestamp, "响应必须包含 timestamp 字段") // 验证时间戳格式(RFC3339) _, err = time.Parse(time.RFC3339, errResp.Timestamp.(string)) assert.NoError(t, err, "时间戳应为有效的 RFC3339 格式") // 验证响应头 requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") contentType := resp.Header.Get("Content-Type") assert.Contains(t, contentType, "application/json", "Content-Type 应为 application/json") // 5xx 错误应该脱敏 - 不暴露原始错误消息,返回错误码对应的标准消息 if tc.expectedHTTP >= 500 { // 验证不包含自定义错误消息(如 "数据库连接失败", "外部 API 调用失败") assert.NotContains(t, errResp.Msg, tc.errorMsg, "5xx 错误不应暴露自定义错误详情,应返回标准消息") // 验证返回的是标准错误消息(从 GetMessage 获取) assert.NotEmpty(t, errResp.Msg, "5xx 错误应返回标准消息") } t.Logf("✓ %s - 格式一致性验证通过 - Code: %s, Status: %d, RequestID: %s", tc.name, errResp.Code, resp.StatusCode, requestID) }) } t.Log("✓ 所有错误响应格式一致性测试通过") } // T039: 创建测试端点触发 panic func TestPanic_BasicPanicRecovery(t *testing.T) { app := setupTestApp() // 创建会触发 panic 的路由 app.Get("/api/test/panic", func(c *fiber.Ctx) error { panic("模拟业务逻辑 panic") }) // 发起请求 req := httptest.NewRequest("GET", "/api/test/panic", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 panic 被捕获并转换为 500 错误 assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码") // 解析响应 body body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err, "panic 响应应为有效的 JSON 格式") // 验证响应字段 assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空") // 验证响应头包含 Request ID requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ Panic 恢复测试通过 - Code: %s, Msg: %s, RequestID: %s", errResp.Code, errResp.Msg, requestID) } // T040: 测试 panic 恢复后服务继续运行 func TestPanic_ServiceContinuesAfterRecovery(t *testing.T) { app := setupTestApp() // 创建会触发 panic 的路由 app.Get("/api/test/panic-endpoint", func(c *fiber.Ctx) error { panic("触发 panic") }) // 创建正常的路由 app.Get("/api/test/normal-endpoint", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "status": "ok", "message": "服务正常运行", }) }) // 第一次请求:触发 panic panicReq := httptest.NewRequest("GET", "/api/test/panic-endpoint", nil) panicResp, err := app.Test(panicReq, -1) require.NoError(t, err) _ = panicResp.Body.Close() assert.Equal(t, 500, panicResp.StatusCode, "panic 应返回 500") // 第二次请求:验证服务仍然正常运行 normalReq := httptest.NewRequest("GET", "/api/test/normal-endpoint", nil) normalResp, err := app.Test(normalReq, -1) require.NoError(t, err) defer func() { _ = normalResp.Body.Close() }() // 验证正常请求仍然成功 assert.Equal(t, 200, normalResp.StatusCode, "panic 后正常请求应成功") // 验证响应内容 body, err := io.ReadAll(normalResp.Body) require.NoError(t, err) var response map[string]interface{} err = json.Unmarshal(body, &response) require.NoError(t, err) assert.Equal(t, "ok", response["status"], "服务应正常运行") t.Log("✓ Panic 后服务继续运行测试通过") } // T041: 测试并发场景下的 panic 处理 func TestPanic_ConcurrentPanicHandling(t *testing.T) { app := setupTestApp() // 创建会随机 panic 的路由 app.Get("/api/test/concurrent-panic/:id", func(c *fiber.Ctx) error { id := c.Params("id") // 奇数 ID 触发 panic,偶数 ID 正常返回 if id == "1" || id == "3" || id == "5" || id == "7" || id == "9" { panic("并发 panic 测试") } return c.JSON(fiber.Map{"id": id, "status": "ok"}) }) // 并发发送 10 个请求 const numRequests = 10 results := make(chan int, numRequests) for i := 1; i <= numRequests; i++ { go func(id int) { req := httptest.NewRequest("GET", "/api/test/concurrent-panic/"+string(rune(id+'0')), nil) resp, err := app.Test(req, -1) if err != nil { results <- 0 return } defer func() { _ = resp.Body.Close() }() results <- resp.StatusCode }(i) } // 收集结果 var successCount, errorCount int for i := 0; i < numRequests; i++ { statusCode := <-results if statusCode == 200 { successCount++ } else if statusCode == 500 { errorCount++ } } // 验证结果:5 个成功(偶数 ID),5 个 panic 恢复(奇数 ID) assert.Equal(t, 5, successCount, "应有 5 个请求成功") assert.Equal(t, 5, errorCount, "应有 5 个 panic 被恢复") t.Logf("✓ 并发 Panic 处理测试通过 - 成功: %d, 错误: %d", successCount, errorCount) } // T042: 验证 panic 时的堆栈跟踪记录 func TestPanic_StackTraceLogging(t *testing.T) { app := setupTestApp() // 创建会触发 panic 的路由(在特定函数中) app.Get("/api/test/panic-with-stack", func(c *fiber.Ctx) error { // 调用一个会 panic 的函数以生成堆栈跟踪 triggerPanicInNestedFunction() return nil }) // 发起请求 req := httptest.NewRequest("GET", "/api/test/panic-with-stack", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 panic 被捕获 assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") // 注意:堆栈跟踪会被记录到日志中,而不是返回给客户端 // 这里我们验证错误消息已被脱敏,不包含内部实现细节 assert.NotContains(t, errResp.Msg, "triggerPanicInNestedFunction", "5xx 错误消息不应暴露内部函数名") t.Log("✓ 堆栈跟踪记录测试通过 - 错误已脱敏,堆栈已记录到日志") } // triggerPanicInNestedFunction 是一个辅助函数,用于生成嵌套的堆栈跟踪 func triggerPanicInNestedFunction() { anotherNestedFunction() } func anotherNestedFunction() { panic("嵌套函数中的 panic") } // TestPanic_NilPointerDereference 测试空指针解引用 panic func TestPanic_NilPointerDereference(t *testing.T) { app := setupTestApp() app.Get("/api/test/nil-pointer", func(c *fiber.Ctx) error { var ptr *string _ = *ptr // 触发空指针 panic return nil }) req := httptest.NewRequest("GET", "/api/test/nil-pointer", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, 500, resp.StatusCode, "空指针 panic 应返回 500") t.Log("✓ 空指针 Panic 测试通过") } // TestPanic_ArrayOutOfBounds 测试数组越界 panic func TestPanic_ArrayOutOfBounds(t *testing.T) { app := setupTestApp() app.Get("/api/test/out-of-bounds", func(c *fiber.Ctx) error { arr := []int{1, 2, 3} _ = arr[10] // 触发数组越界 panic return nil }) req := httptest.NewRequest("GET", "/api/test/out-of-bounds", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, 500, resp.StatusCode, "数组越界 panic 应返回 500") t.Log("✓ 数组越界 Panic 测试通过") } // T045: 测试参数验证失败 -> Warn 级别日志 func TestErrorClassification_ValidationError_WarnLevel(t *testing.T) { app := setupTestApp() app.Post("/api/test/validation-warn", func(c *fiber.Ctx) error { // 模拟参数验证失败 (客户端错误 1xxx -> Warn 级别) return errors.New( errors.CodeInvalidParam, "用户名格式不正确", ) }) req := httptest.NewRequest("POST", "/api/test/validation-warn", nil) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 400, resp.StatusCode, "参数验证失败应返回 400") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") t.Logf("✓ 参数验证失败测试通过 (Warn 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg) } // T046: 测试权限不足 -> Warn 级别日志 func TestErrorClassification_PermissionDenied_WarnLevel(t *testing.T) { app := setupTestApp() app.Get("/api/test/permission-warn", func(c *fiber.Ctx) error { // 模拟权限不足 (客户端错误 1xxx -> Warn 级别) return errors.New( errors.CodeForbidden, "您没有权限访问此资源", ) }) req := httptest.NewRequest("GET", "/api/test/permission-warn", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 403, resp.StatusCode, "权限不足应返回 403") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") // 客户端错误可以保留自定义消息,验证包含权限相关提示 assert.Contains(t, errResp.Msg, "权限", "错误消息应包含权限相关提示") t.Logf("✓ 权限不足测试通过 (Warn 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg) } // T047: 测试数据库错误 -> Error 级别日志 func TestErrorClassification_DatabaseError_ErrorLevel(t *testing.T) { app := setupTestApp() app.Get("/api/test/database-error", func(c *fiber.Ctx) error { // 模拟数据库错误 (服务端错误 2xxx -> Error 级别) return errors.New( errors.CodeDatabaseError, "pq: relation 'users' does not exist", // 敏感的数据库错误信息 ) }) req := httptest.NewRequest("GET", "/api/test/database-error", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 500, resp.StatusCode, "数据库错误应返回 500") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.Equal(t, "数据库错误", errResp.Msg, "5xx 错误应返回标准消息") // 验证敏感信息已被隐藏 assert.NotContains(t, errResp.Msg, "pq:", "不应暴露数据库驱动信息") assert.NotContains(t, errResp.Msg, "relation", "不应暴露数据库表结构") t.Logf("✓ 数据库错误测试通过 (Error 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg) } // T048: 验证敏感信息隐藏 (数据库错误不暴露 SQL) func TestErrorClassification_SensitiveInfoHidden(t *testing.T) { app := setupTestApp() testCases := []struct { name string path string errorCode int sensitiveMsg string expectedStatus int expectedMsg string shouldNotContain []string }{ { name: "数据库连接错误", path: "/api/test/db-connection", errorCode: errors.CodeDatabaseError, sensitiveMsg: "connection refused: tcp 192.168.1.100:5432", expectedStatus: 500, expectedMsg: "数据库错误", shouldNotContain: []string{"192.168.1.100", "5432", "connection refused"}, }, { name: "SQL 语法错误", path: "/api/test/sql-syntax", errorCode: errors.CodeDatabaseError, sensitiveMsg: "syntax error at or near 'SELECT * FROM users WHERE id='", expectedStatus: 500, expectedMsg: "数据库错误", shouldNotContain: []string{"SELECT", "FROM users", "syntax error"}, }, { name: "Redis 连接错误", path: "/api/test/redis-error", errorCode: errors.CodeRedisError, sensitiveMsg: "dial tcp 127.0.0.1:6379: connect: connection refused", expectedStatus: 500, expectedMsg: "缓存服务错误", shouldNotContain: []string{"127.0.0.1", "6379", "dial tcp"}, }, { name: "任务队列错误", path: "/api/test/queue-error", errorCode: errors.CodeTaskQueueError, sensitiveMsg: "failed to enqueue task: redis: nil", expectedStatus: 500, expectedMsg: "任务队列错误", shouldNotContain: []string{"enqueue", "redis: nil"}, }, } for _, tc := range testCases { tc := tc // 捕获循环变量 app.Get(tc.path, func(c *fiber.Ctx) error { return errors.New(tc.errorCode, tc.sensitiveMsg) }) } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", tc.path, nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, tc.expectedStatus, resp.StatusCode, "HTTP 状态码应正确") body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) // 验证返回的是标准消息,不是自定义的敏感消息 assert.Equal(t, tc.expectedMsg, errResp.Msg, "应返回标准错误消息") // 验证敏感信息已被隐藏 for _, sensitive := range tc.shouldNotContain { assert.NotContains(t, errResp.Msg, sensitive, "错误消息不应包含敏感信息: %s", sensitive) } t.Logf("✓ %s - 敏感信息已隐藏", tc.name) }) } t.Log("✓ 所有敏感信息隐藏测试通过") } // T049: 测试限流错误 -> 429 响应 func TestErrorClassification_RateLimitExceeded_Returns429(t *testing.T) { app := setupTestApp() app.Get("/api/test/rate-limit", func(c *fiber.Ctx) error { // 模拟触发限流 return errors.New( errors.CodeTooManyRequests, "您的请求过于频繁,请稍后重试", ) }) req := httptest.NewRequest("GET", "/api/test/rate-limit", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 429, resp.StatusCode, "限流应返回 429 状态码") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") // 客户端错误可以保留自定义消息,验证包含限流相关提示 assert.True(t, contains(errResp.Msg, "请求过多") || contains(errResp.Msg, "请求过于频繁"), "错误消息应包含限流提示") // 验证响应头 requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ 限流错误测试通过 - Code: %s, Status: %d, Msg: %s", errResp.Code, resp.StatusCode, errResp.Msg) } // contains 辅助函数用于字符串包含检查 func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) } func stringContains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // T050: 测试服务不可用 -> 503 响应 func TestErrorClassification_ServiceUnavailable_Returns503(t *testing.T) { app := setupTestApp() app.Get("/api/test/service-unavailable", func(c *fiber.Ctx) error { // 模拟服务不可用 (如外部 API 不可用) return errors.New( errors.CodeServiceUnavailable, "外部认证服务暂时不可用", ) }) req := httptest.NewRequest("GET", "/api/test/service-unavailable", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, 503, resp.StatusCode, "服务不可用应返回 503 状态码") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") // 5xx 错误应返回标准消息,而不是自定义消息 assert.Equal(t, "服务暂时不可用", errResp.Msg, "应返回标准错误消息") assert.NotContains(t, errResp.Msg, "外部认证服务", "不应暴露内部服务细节") t.Logf("✓ 服务不可用错误测试通过 - Code: %s, Status: %d, Msg: %s", errResp.Code, resp.StatusCode, errResp.Msg) } // ===== Phase 6: User Story 4 - 错误追踪和调试支持 ===== // T054: 测试错误日志完整性(包含 Request ID) func TestErrorTracking_LogCompleteness_IncludesRequestID(t *testing.T) { app := setupTestApp() app.Post("/api/test/error-log-completeness", func(c *fiber.Ctx) error { // 触发一个错误,验证日志包含 Request ID return errors.New( errors.CodeInvalidParam, "测试错误日志完整性", ) }) // 发起请求(带自定义 Request ID) req := httptest.NewRequest("POST", "/api/test/error-log-completeness", nil) customRequestID := "test-request-id-12345" req.Header.Set("X-Request-ID", customRequestID) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证响应包含 Request ID responseRequestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, responseRequestID, "响应头应包含 X-Request-ID") // 验证响应成功 assert.Equal(t, 400, resp.StatusCode, "应返回 400 错误") body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") t.Logf("✓ 错误日志完整性测试通过 - RequestID: %s", responseRequestID) t.Log(" 注意:实际的日志完整性需要检查日志文件,确认包含 request_id, method, path, ip 等字段") } // T055: 测试请求上下文记录(路径、方法、参数) func TestErrorTracking_RequestContext_AllFields(t *testing.T) { app := setupTestApp() app.Post("/api/test/context-logging", func(c *fiber.Ctx) error { // 触发一个错误,验证日志包含完整的请求上下文 return errors.New( errors.CodeInvalidParam, "测试请求上下文记录", ) }) // 构造带有 Query 参数和 Body 的请求 requestBody := `{"username": "testuser", "email": "test@example.com"}` req := httptest.NewRequest("POST", "/api/test/context-logging?page=1&size=10", strings.NewReader(requestBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "TestClient/1.0") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证响应 assert.Equal(t, 400, resp.StatusCode, "应返回 400 错误") requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ 请求上下文记录测试通过 - RequestID: %s", requestID) t.Log(" 注意:需要检查日志文件,确认包含:") t.Log(" - method: POST") t.Log(" - path: /api/test/context-logging") t.Log(" - query: page=1&size=10") t.Log(" - body: 请求体内容(限制 50KB)") t.Log(" - user_agent: TestClient/1.0") t.Log(" - ip: 客户端 IP") } // T056: 测试 panic 堆栈跟踪记录(指明 panic 位置) func TestErrorTracking_PanicStackTrace_IncludesLocation(t *testing.T) { app := setupTestApp() app.Get("/api/test/panic-stack-trace", func(c *fiber.Ctx) error { // 在一个可识别的函数中触发 panic panicInSpecificLocation() return nil }) req := httptest.NewRequest("GET", "/api/test/panic-stack-trace", nil) resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 panic 被捕获 assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码") // 验证响应格式 body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") // 验证响应中不包含堆栈跟踪(敏感信息已脱敏) assert.NotContains(t, errResp.Msg, "panicInSpecificLocation", "5xx 错误消息不应暴露内部函数名") requestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID") t.Logf("✓ Panic 堆栈跟踪测试通过 - RequestID: %s", requestID) t.Log(" 注意:需要检查日志文件,确认包含:") t.Log(" - 完整的堆栈跟踪(runtime/debug.Stack())") t.Log(" - 文件名和行号") t.Log(" - 函数名(panicInSpecificLocation)") t.Log(" - 从 panic 发生点到 recover 捕获点的完整调用链") } // panicInSpecificLocation 辅助函数,用于触发可追踪的 panic func panicInSpecificLocation() { panic("这是一个特定位置的 panic,用于测试堆栈跟踪") } // T057: 测试使用 Request ID 追踪请求流程 func TestErrorTracking_RequestIDTracing_EndToEnd(t *testing.T) { app := setupTestApp() // 创建多个端点模拟完整的请求流程 app.Post("/api/test/trace/step1", func(c *fiber.Ctx) error { // 第一步:参数验证失败 return errors.New( errors.CodeInvalidParam, "步骤 1: 参数验证失败", ) }) app.Post("/api/test/trace/step2", func(c *fiber.Ctx) error { // 第二步:业务逻辑错误 return errors.New( errors.CodeDatabaseError, "步骤 2: 数据库操作失败", ) }) app.Post("/api/test/trace/step3", func(c *fiber.Ctx) error { // 第三步:触发 panic panic("步骤 3: 发生 panic") }) // 使用相同的 Request ID 追踪整个流程 traceID := "trace-test-" + time.Now().Format("20060102-150405") testCases := []struct { name string path string expectedStatus int stepDesc string }{ { name: "步骤1-参数验证", path: "/api/test/trace/step1", expectedStatus: 400, stepDesc: "参数验证失败应记录 Warn 级别日志", }, { name: "步骤2-数据库错误", path: "/api/test/trace/step2", expectedStatus: 500, stepDesc: "数据库错误应记录 Error 级别日志", }, { name: "步骤3-Panic恢复", path: "/api/test/trace/step3", expectedStatus: 500, stepDesc: "Panic 应记录 Error 级别日志和堆栈跟踪", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("POST", tc.path, nil) req.Header.Set("X-Request-ID", traceID) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() // 验证 HTTP 状态码 assert.Equal(t, tc.expectedStatus, resp.StatusCode, "%s 应返回 %d 状态码", tc.name, tc.expectedStatus) // 验证响应包含相同的 Request ID responseRequestID := resp.Header.Get("X-Request-ID") assert.NotEmpty(t, responseRequestID, "响应头应包含 X-Request-ID") body, err := io.ReadAll(resp.Body) require.NoError(t, err) var errResp ErrorResponse err = json.Unmarshal(body, &errResp) require.NoError(t, err) assert.NotEmpty(t, errResp.Code, "错误码不应为空") assert.NotEmpty(t, errResp.Msg, "错误消息不应为空") t.Logf(" ✓ %s 完成 - RequestID: %s, Status: %d", tc.name, responseRequestID, resp.StatusCode) t.Logf(" %s", tc.stepDesc) }) } t.Logf("✓ Request ID 追踪测试通过 - TraceID: %s", traceID) t.Log(" 注意:可以在日志文件中搜索 TraceID,追踪完整的请求流程:") t.Logf(" grep '%s' tests/integration/logs/error_handler_test.log", traceID) t.Log(" 应该能看到 3 个步骤的完整日志记录") }