veza/veza-backend-api/tests/contract/api_contract_test.go
2025-12-25 15:18:44 +01:00

326 lines
9.2 KiB
Go

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"
"veza-backend-api/internal/handlers"
apperrors "veza-backend-api/internal/errors"
)
// 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")
}