feat: Implement error handling and response structure for API

- Added standardized error response structure in `errors.go` for consistent error handling across the API.
- Implemented specific response functions for various HTTP status codes (400, 401, 403, 404, 500) to enhance error reporting.
- Introduced validation error handling to provide detailed feedback on input validation issues.

test: Add comprehensive tests for share handler functionality

- Created a suite of tests for share handler endpoints, covering scenarios for creating, listing, deleting shares, and managing share links.
- Included tests for permission checks, validation errors, and edge cases such as unauthorized access and invalid document IDs.

chore: Set up test utilities and database for integration tests

- Established a base handler suite for common setup tasks in tests, including database initialization and teardown.
- Implemented test data seeding to facilitate consistent testing across different scenarios.

migration: Add public sharing support in the database schema

- Modified the `documents` table to include `share_token` and `is_public` columns for managing public document sharing.
- Added constraints to ensure data integrity, preventing public documents from lacking a share token.
This commit is contained in:
M1ngdaXie
2026-01-05 15:25:46 -08:00
parent 7f5f32179b
commit 8ae7fd96e8
15 changed files with 1870 additions and 118 deletions

View File

@@ -0,0 +1,129 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"github.com/M1ngdaXie/realtime-collab/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
)
// BaseHandlerSuite provides common setup for all handler tests
type BaseHandlerSuite struct {
suite.Suite
store *store.PostgresStore
cleanup func()
testData *store.TestData
jwtSecret string
frontendURL string
}
// SetupSuite runs once before all tests in the suite
func (s *BaseHandlerSuite) SetupSuite() {
s.store, s.cleanup = store.SetupTestDB(s.T())
s.jwtSecret = "test-secret-key-do-not-use-in-production"
s.frontendURL = "http://localhost:5173"
gin.SetMode(gin.TestMode)
}
// SetupTest runs before each test
func (s *BaseHandlerSuite) SetupTest() {
ctx := context.Background()
err := store.TruncateAllTables(ctx, s.store)
s.Require().NoError(err, "Failed to truncate tables")
testData, err := store.SeedTestData(ctx, s.store)
s.Require().NoError(err, "Failed to seed test data")
s.testData = testData
}
// TearDownSuite runs once after all tests in the suite
func (s *BaseHandlerSuite) TearDownSuite() {
if s.cleanup != nil {
s.cleanup()
}
}
// Helper: makeAuthRequest creates an authenticated HTTP request and returns recorder + request
func (s *BaseHandlerSuite) makeAuthRequest(method, path string, body interface{}, userID uuid.UUID) (*httptest.ResponseRecorder, *http.Request, error) {
var bodyReader *bytes.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal body: %w", err)
}
bodyReader = bytes.NewReader(jsonBody)
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set("Content-Type", "application/json")
// Generate JWT token
token, err := store.GenerateTestJWT(userID, s.jwtSecret)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate JWT: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
return w, req, nil
}
// Helper: makePublicRequest creates an unauthenticated HTTP request
func (s *BaseHandlerSuite) makePublicRequest(method, path string, body interface{}) (*httptest.ResponseRecorder, *http.Request, error) {
var bodyReader *bytes.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal body: %w", err)
}
bodyReader = bytes.NewReader(jsonBody)
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
return w, req, nil
}
// Helper: parseErrorResponse parses an ErrorResponse from the response
func (s *BaseHandlerSuite) parseErrorResponse(w *httptest.ResponseRecorder) ErrorResponse {
var errResp ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &errResp)
s.Require().NoError(err, "Failed to parse error response")
return errResp
}
// Helper: assertErrorResponse checks that the response matches expected error
func (s *BaseHandlerSuite) assertErrorResponse(w *httptest.ResponseRecorder, statusCode int, errorType string, messageContains string) {
s.Equal(statusCode, w.Code, "Status code mismatch")
errResp := s.parseErrorResponse(w)
s.Equal(errorType, errResp.Error, "Error type mismatch")
if messageContains != "" {
s.Contains(errResp.Message, messageContains, "Error message should contain expected text")
}
}
// Helper: assertSuccessResponse checks that the response is successful
func (s *BaseHandlerSuite) assertSuccessResponse(w *httptest.ResponseRecorder, statusCode int) {
s.Equal(statusCode, w.Code, "Status code mismatch. Body: %s", w.Body.String())
}
// Helper: parseJSONResponse parses a JSON response into the target struct
func (s *BaseHandlerSuite) parseJSONResponse(w *httptest.ResponseRecorder, target interface{}) {
err := json.Unmarshal(w.Body.Bytes(), target)
s.Require().NoError(err, "Failed to parse JSON response: %s", w.Body.String())
}