package contract import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/handlers" ) // INT-009: API Contract Tests // These tests verify that API responses match the expected contract // between frontend and backend, ensuring compatibility. // APIResponse represents the standard API response envelope type APIResponse struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error interface{} `json:"error,omitempty"` } // ErrorResponse represents the standard error response structure type ErrorResponse struct { Code int `json:"code"` Message string `json:"message"` Details []apperrors.ErrorDetail `json:"details,omitempty"` RequestID string `json:"request_id,omitempty"` Timestamp string `json:"timestamp"` Context map[string]interface{} `json:"context,omitempty"` } // PaginationData represents the standard pagination structure type PaginationData struct { Page int `json:"page"` Limit int `json:"limit"` Total int64 `json:"total"` TotalPages int `json:"total_pages"` HasNext bool `json:"has_next"` HasPrev bool `json:"has_prev"` NextCursor string `json:"next_cursor,omitempty"` PrevCursor string `json:"prev_cursor,omitempty"` } // TestErrorResponseFormat verifies that error responses follow the standard format // INT-009: Verify error response contract func TestErrorResponseFormat(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() // Add request ID middleware for testing router.Use(func(c *gin.Context) { c.Set("request_id", "test-request-id") c.Next() }) // Test endpoint that returns an error router.GET("/test-error", func(c *gin.Context) { appErr := apperrors.New(apperrors.ErrCodeValidation, "Test validation error") handlers.RespondWithAppError(c, appErr) }) // Make request w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-error", nil) router.ServeHTTP(w, req) // Verify response assert.Equal(t, http.StatusBadRequest, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Verify envelope structure assert.False(t, response.Success) assert.Nil(t, response.Data) assert.NotNil(t, response.Error) // Verify error structure errorBytes, _ := json.Marshal(response.Error) var errorResp ErrorResponse err = json.Unmarshal(errorBytes, &errorResp) require.NoError(t, err) // Verify error fields assert.Equal(t, int(apperrors.ErrCodeValidation), errorResp.Code) assert.Equal(t, "Test validation error", errorResp.Message) assert.Equal(t, "test-request-id", errorResp.RequestID) assert.NotEmpty(t, errorResp.Timestamp) // Verify timestamp is ISO 8601 (RFC3339) _, err = time.Parse(time.RFC3339, errorResp.Timestamp) assert.NoError(t, err, "Timestamp should be in RFC3339 format") } // TestSuccessResponseFormat verifies that success responses follow the standard format // INT-009: Verify success response contract func TestSuccessResponseFormat(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() // Test endpoint that returns success router.GET("/test-success", func(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{ "message": "Test success", "data": gin.H{"id": "123"}, }) }) // Make request w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-success", nil) router.ServeHTTP(w, req) // Verify response assert.Equal(t, http.StatusOK, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Verify envelope structure assert.True(t, response.Success) assert.NotNil(t, response.Data) assert.Nil(t, response.Error) } // TestPaginationFormat verifies that paginated responses follow the standard format // INT-009: Verify pagination contract func TestPaginationFormat(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() // Test endpoint that returns paginated data router.GET("/test-pagination", func(c *gin.Context) { pagination := handlers.BuildPaginationData(1, 20, 100) handlers.RespondSuccess(c, http.StatusOK, gin.H{ "items": []string{"item1", "item2"}, "pagination": pagination, }) }) // Make request w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-pagination", nil) router.ServeHTTP(w, req) // Verify response assert.Equal(t, http.StatusOK, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Verify envelope structure assert.True(t, response.Success) assert.NotNil(t, response.Data) // Extract pagination from data dataBytes, _ := json.Marshal(response.Data) var dataMap map[string]interface{} err = json.Unmarshal(dataBytes, &dataMap) require.NoError(t, err) paginationBytes, _ := json.Marshal(dataMap["pagination"]) var pagination PaginationData err = json.Unmarshal(paginationBytes, &pagination) require.NoError(t, err) // Verify pagination fields assert.Equal(t, 1, pagination.Page) assert.Equal(t, 20, pagination.Limit) assert.Equal(t, int64(100), pagination.Total) assert.Equal(t, 5, pagination.TotalPages) assert.True(t, pagination.HasNext) assert.False(t, pagination.HasPrev) } // TestDateTimeFormat verifies that date/time fields use ISO 8601 format // INT-009: Verify date/time format contract func TestDateTimeFormat(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() // Test endpoint that returns a timestamp router.GET("/test-datetime", func(c *gin.Context) { now := time.Now().UTC() handlers.RespondSuccess(c, http.StatusOK, gin.H{ "timestamp": now.Format(time.RFC3339), "created_at": now.Format(time.RFC3339), }) }) // Make request w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-datetime", nil) router.ServeHTTP(w, req) // Verify response assert.Equal(t, http.StatusOK, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Extract data dataBytes, _ := json.Marshal(response.Data) var dataMap map[string]interface{} err = json.Unmarshal(dataBytes, &dataMap) require.NoError(t, err) // Verify timestamp format timestamp, ok := dataMap["timestamp"].(string) require.True(t, ok, "Timestamp should be a string") _, err = time.Parse(time.RFC3339, timestamp) assert.NoError(t, err, "Timestamp should be in RFC3339 format") createdAt, ok := dataMap["created_at"].(string) require.True(t, ok, "CreatedAt should be a string") _, err = time.Parse(time.RFC3339, createdAt) assert.NoError(t, err, "CreatedAt should be in RFC3339 format") } // TestAPIResponseEnvelope verifies that all responses use the APIResponse envelope // INT-009: Verify API response envelope contract func TestAPIResponseEnvelope(t *testing.T) { testCases := []struct { name string handler gin.HandlerFunc expectedStatus int expectSuccess bool }{ { name: "Success response", handler: func(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{"test": "data"}) }, expectedStatus: http.StatusOK, expectSuccess: true, }, { name: "Error response", handler: func(c *gin.Context) { appErr := apperrors.New(apperrors.ErrCodeNotFound, "Not found") handlers.RespondWithAppError(c, appErr) }, expectedStatus: http.StatusNotFound, expectSuccess: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.GET("/test", tc.handler) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test", nil) router.ServeHTTP(w, req) assert.Equal(t, tc.expectedStatus, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Verify envelope structure assert.Equal(t, tc.expectSuccess, response.Success) if tc.expectSuccess { assert.NotNil(t, response.Data) assert.Nil(t, response.Error) } else { assert.Nil(t, response.Data) assert.NotNil(t, response.Error) } }) } } // TestSnakeCaseNaming verifies that all JSON fields use snake_case // INT-009: Verify naming convention contract func TestSnakeCaseNaming(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.GET("/test-naming", func(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{ "user_id": "123", "created_at": time.Now().UTC().Format(time.RFC3339), "first_name": "John", "last_name": "Doe", }) }) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-naming", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var response APIResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Verify that JSON uses snake_case dataBytes, _ := json.Marshal(response.Data) var dataMap map[string]interface{} err = json.Unmarshal(dataBytes, &dataMap) require.NoError(t, err) // Check that keys are in snake_case assert.Contains(t, dataMap, "user_id") assert.Contains(t, dataMap, "created_at") assert.Contains(t, dataMap, "first_name") assert.Contains(t, dataMap, "last_name") }