package response import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func init() { gin.SetMode(gin.TestMode) } // --- AppError tests --- func TestAppError_Error_WithDetail(t *testing.T) { e := &AppError{ Code: ErrNotFound, Message: "resource missing", Detail: "ID=42", } assert.Equal(t, "NOT_FOUND: resource missing (ID=42)", e.Error()) } func TestAppError_Error_WithoutDetail(t *testing.T) { e := &AppError{ Code: ErrBadRequest, Message: "bad input", } assert.Equal(t, "BAD_REQUEST: bad input", e.Error()) } func TestNewAppError(t *testing.T) { e := NewAppError(ErrValidation, "invalid field", http.StatusBadRequest) assert.Equal(t, ErrValidation, e.Code) assert.Equal(t, "invalid field", e.Message) assert.Equal(t, http.StatusBadRequest, e.Status) assert.Empty(t, e.Detail) } func TestAppError_WithDetail(t *testing.T) { e := NewAppError(ErrValidation, "invalid field", http.StatusBadRequest) e2 := e.WithDetail("field 'name' is required") assert.Same(t, e, e2) // WithDetail returns the same pointer assert.Equal(t, "field 'name' is required", e.Detail) } // --- Convenience constructor tests --- func TestErrInternalError(t *testing.T) { e := ErrInternalError("something broke") assert.Equal(t, ErrInternal, e.Code) assert.Equal(t, "something broke", e.Message) assert.Equal(t, http.StatusInternalServerError, e.Status) } func TestErrNotFoundError(t *testing.T) { e := ErrNotFoundError("Account") assert.Equal(t, ErrNotFound, e.Code) assert.Equal(t, "Account not found", e.Message) assert.Equal(t, http.StatusNotFound, e.Status) } func TestErrBadRequestError(t *testing.T) { e := ErrBadRequestError("invalid body") assert.Equal(t, ErrBadRequest, e.Code) assert.Equal(t, http.StatusBadRequest, e.Status) } func TestErrUnauthorizedError(t *testing.T) { e := ErrUnauthorizedError("no token") assert.Equal(t, ErrUnauthorized, e.Code) assert.Equal(t, http.StatusUnauthorized, e.Status) } func TestErrForbiddenError(t *testing.T) { e := ErrForbiddenError("no access") assert.Equal(t, ErrForbidden, e.Code) assert.Equal(t, http.StatusForbidden, e.Status) } func TestErrValidationError(t *testing.T) { e := ErrValidationError("name required") assert.Equal(t, ErrValidation, e.Code) assert.Equal(t, http.StatusBadRequest, e.Status) } func TestErrConflictError(t *testing.T) { e := ErrConflictError("email exists") assert.Equal(t, ErrConflict, e.Code) assert.Equal(t, http.StatusConflict, e.Status) } func TestErrRateLimitError(t *testing.T) { e := ErrRateLimitError("too many requests") assert.Equal(t, ErrRateLimit, e.Code) assert.Equal(t, http.StatusTooManyRequests, e.Status) } // --- ErrorToHTTPStatus tests --- func TestErrorToHTTPStatus(t *testing.T) { tests := []struct { code ErrorCode expected int }{ {ErrNotFound, http.StatusNotFound}, {ErrAccountNotFound, http.StatusNotFound}, {ErrInboxNotFound, http.StatusNotFound}, {ErrContactNotFound, http.StatusNotFound}, {ErrConversationNotFound, http.StatusNotFound}, {ErrMessageNotFound, http.StatusNotFound}, {ErrUserNotFound, http.StatusNotFound}, {ErrBadRequest, http.StatusBadRequest}, {ErrValidation, http.StatusBadRequest}, {ErrUnauthorized, http.StatusUnauthorized}, {ErrForbidden, http.StatusForbidden}, {ErrChannelNotEnabled, http.StatusForbidden}, {ErrConflict, http.StatusConflict}, {ErrDuplicateRecord, http.StatusConflict}, {ErrChannelInvalid, http.StatusConflict}, {ErrCaptainAssistantDisabled, http.StatusConflict}, {ErrRateLimit, http.StatusTooManyRequests}, {ErrCopilotProviderRateLimited, http.StatusTooManyRequests}, {ErrCopilotProviderTimeout, http.StatusGatewayTimeout}, {ErrServiceUnavail, http.StatusServiceUnavailable}, {ErrCopilotNotConfigured, http.StatusServiceUnavailable}, {ErrCopilotProviderAuth, http.StatusServiceUnavailable}, {ErrCopilotProviderUnreachable, http.StatusServiceUnavailable}, {ErrCopilotModelNotFound, http.StatusServiceUnavailable}, // Default case {ErrInternal, http.StatusInternalServerError}, {ErrorCode("UNKNOWN_CODE"), http.StatusInternalServerError}, } for _, tt := range tests { t.Run(string(tt.code), func(t *testing.T) { assert.Equal(t, tt.expected, ErrorToHTTPStatus(tt.code)) }) } } // --- Response helper tests --- func newTestContext() (*gin.Context, *httptest.ResponseRecorder) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/", nil) return c, w } func TestOK(t *testing.T) { c, w := newTestContext() OK(c, map[string]string{"key": "value"}) assert.Equal(t, http.StatusOK, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.True(t, resp.Success) assert.NotNil(t, resp.Data) assert.Nil(t, resp.Error) assert.Nil(t, resp.Meta) } func TestOK_NilData(t *testing.T) { c, w := newTestContext() OK(c, nil) assert.Equal(t, http.StatusOK, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.True(t, resp.Success) // Data is omitted when nil due to omitempty } func TestOKWithMeta(t *testing.T) { c, w := newTestContext() OKWithMeta(c, []string{"a", "b"}, 2, 10, 25) assert.Equal(t, http.StatusOK, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.True(t, resp.Success) assert.NotNil(t, resp.Data) require.NotNil(t, resp.Meta) assert.Equal(t, 2, resp.Meta.Page) assert.Equal(t, 10, resp.Meta.PerPage) assert.Equal(t, int64(25), resp.Meta.TotalCount) assert.Nil(t, resp.Error) } func TestCreated(t *testing.T) { c, w := newTestContext() Created(c, map[string]int{"id": 1}) assert.Equal(t, http.StatusCreated, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.True(t, resp.Success) assert.NotNil(t, resp.Data) } func TestNoContent(t *testing.T) { // Use a real router since CreateTestContext doesn't fully set up the // response writer for Status() calls. r := gin.New() r.GET("/test", func(c *gin.Context) { NoContent(c) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusNoContent, w.Code) assert.Empty(t, w.Body.Bytes()) } func TestAbortWithError(t *testing.T) { c, w := newTestContext() appErr := NewAppError(ErrNotFound, "resource missing", http.StatusNotFound).WithDetail("ID=99") AbortWithError(c, appErr) assert.Equal(t, http.StatusNotFound, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(t, resp.Success) require.NotNil(t, resp.Error) assert.Equal(t, ErrNotFound, resp.Error.Code) assert.Equal(t, "resource missing", resp.Error.Message) assert.Equal(t, "ID=99", resp.Error.Detail) assert.Nil(t, resp.Data) } func TestAbortWithError_NoDetail(t *testing.T) { c, w := newTestContext() appErr := NewAppError(ErrBadRequest, "bad request", http.StatusBadRequest) AbortWithError(c, appErr) assert.Equal(t, http.StatusBadRequest, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(t, resp.Success) require.NotNil(t, resp.Error) assert.Equal(t, ErrBadRequest, resp.Error.Code) assert.Empty(t, resp.Error.Detail) } func TestAbortWithStatusError(t *testing.T) { c, w := newTestContext() AbortWithStatusError(c, http.StatusUnauthorized, ErrUnauthorized, "token expired") assert.Equal(t, http.StatusUnauthorized, w.Code) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(t, resp.Success) require.NotNil(t, resp.Error) assert.Equal(t, ErrUnauthorized, resp.Error.Code) assert.Equal(t, "token expired", resp.Error.Message) assert.Empty(t, resp.Error.Detail) // AbortWithStatusError does not set Detail } func TestAbortWithStatusError_LocalizesRawValidationMessage(t *testing.T) { c, w := newTestContext() raw := "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag" AbortWithStatusError(c, http.StatusBadRequest, ErrValidation, raw) var resp APIResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.NotNil(t, resp.Error) assert.Equal(t, "请求参数不符合要求", resp.Error.Message) } func TestAbortWithStatusError_ForbidsFurtherHandlers(t *testing.T) { // Verify that abort truly prevents subsequent handlers from running router := gin.New() router.GET("/test", func(c *gin.Context) { AbortWithStatusError(c, http.StatusForbidden, ErrForbidden, "denied") }, func(c *gin.Context) { t.Fatal("second handler should not be called after abort") }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) } func TestAPIResponse_JSONSerialization(t *testing.T) { resp := APIResponse{ Success: true, Data: "test data", } data, err := json.Marshal(resp) require.NoError(t, err) // Should contain "success" and "data" but not "error" or "meta" (omitempty) s := string(data) assert.Contains(t, s, `"success":true`) assert.Contains(t, s, `"data":"test data"`) assert.NotContains(t, s, `"error"`) assert.NotContains(t, s, `"meta"`) } func TestAPIResponse_ErrorJSONSerialization(t *testing.T) { resp := APIResponse{ Success: false, Error: &ErrorBody{ Code: ErrNotFound, Message: "not here", }, } data, err := json.Marshal(resp) require.NoError(t, err) s := string(data) assert.Contains(t, s, `"success":false`) assert.Contains(t, s, `"error"`) assert.Contains(t, s, `"code":"NOT_FOUND"`) assert.Contains(t, s, `"message":"not here"`) assert.NotContains(t, s, `"data"`) assert.NotContains(t, s, `"meta"`) }