feat: add GET /api/saved-filters/:id endpoint with comprehensive tests

Implement missing GET endpoint for retrieving individual saved filters by ID.
This completes the CRUD API for saved filters and enables mobile/SPA clients
to fetch filter details on-demand.

Backend Implementation:
- Add GetSavedFilterByID() handler method (internal/handlers/filters.go)
  - Parse filter ID from URL parameter
  - Validate UUID format, return 400 for invalid IDs
  - Call service layer for business logic + ownership verification
  - Return 404 if filter not found or doesn't belong to user
  - Return 200 with filter object including filters JSONB

- Add GetSavedFilterByID() service method (internal/services/filters.go)
  - Call existing database query GetSavedFilterByID
  - Verify filter exists and belongs to user
  - Return descriptive error: "filter not found or access denied"
  - Reuses existing database query (no new SQL needed)

- Register GET /:id route (internal/router/filters.go)
  - Add route before existing GET "" route
  - Follows RESTful routing conventions

Integration Tests (cmd/server/tests/filters_test.go):
- Test success case: Create filter, retrieve by ID, verify data
- Test error case: Invalid UUID format returns 400
- Test error case: Non-existent filter returns 404
- Test error case: No authentication returns 401
- Test security case: Cross-user access returns 404 (not 403)
  - Admin creates filter, regular user tries to access
  - Uses setup.Token (admin) and setup.RegularToken
  - Verifies information leakage prevention

API Design:
- Endpoint: GET /api/saved-filters/:id
- Authentication: JWT token required
- Response format: SavedFilterResponse with filters as JSON
- Error responses: 400 (invalid ID), 401 (no auth), 404 (not found)
- Security: Returns 404 for cross-user access (hides existence)

Benefits:
- Completes CRUD API for saved filters
- Enables future mobile/SPA clients
- Follows existing handler/service/test patterns
- Comprehensive security testing
- No database changes required (reuses existing queries)

Follows PROJECT_GUIDELINES.md service layer architecture and testing patterns.
This commit is contained in:
2026-03-21 22:37:19 -04:00
parent ba8133fb2d
commit 0960e36f30
4 changed files with 155 additions and 0 deletions
+101
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -213,4 +214,104 @@ func TestSavedFilters(t *testing.T) {
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
})
t.Run("GET /api/saved-filters/:id retrieves single filter", func(t *testing.T) {
// First create a filter
reqBody := map[string]interface{}{
"name": "Test Filter",
"resource_type": "media-items",
"filters": map[string]string{"search": "test"},
}
body, _ := json.Marshal(reqBody)
createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
filterID := createdFilter["id"].(string)
// Now retrieve the filter by ID
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
getReq.Header.Set("Authorization", "Bearer "+setup.Token)
getResp, err := client.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
var retrievedFilter map[string]interface{}
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
assert.Equal(t, "Test Filter", retrievedFilter["name"])
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
assert.Equal(t, filterID, retrievedFilter["id"])
assert.NotEmpty(t, retrievedFilter["filters"])
})
t.Run("GET /api/saved-filters/:id without auth returns 401", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GET /api/saved-filters/:id with invalid UUID returns 400", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/invalid-uuid", nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GET /api/saved-filters/:id with non-existent filter returns 404", func(t *testing.T) {
fakeID := uuid.New().String()
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+fakeID, nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
// Create filter with admin user (setup.Token)
reqBody := map[string]interface{}{
"name": "Admin Filter",
"resource_type": "media-items",
"filters": map[string]string{"search": "test"},
}
body, _ := json.Marshal(reqBody)
createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
filterID := createdFilter["id"].(string)
// Try to access with regular user (setup.RegularToken)
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
getResp, err := client.Do(httpReq)
require.NoError(t, err)
defer getResp.Body.Close()
// Should return 404 (not 403 - hide existence)
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
})
}
+40
View File
@@ -86,6 +86,46 @@ func (h *FiltersHandler) GetSavedFilters(c *echo.Context) error {
return c.JSON(http.StatusOK, response)
}
// GetSavedFilterByID - GET /api/saved-filters/:id
// Retrieve a single saved filter by ID
func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
// Parse filter ID from URL parameter
filterID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid filter ID",
})
}
// Use service layer (includes business logic + ownership verification)
filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID)
if err != nil {
if err.Error() == "filter not found or access denied" {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "filter not found",
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch filter",
})
}
// Return JSON response (JSONB handled automatically)
response := SavedFilterResponse{
ID: filterID,
Name: filter.Name,
ResourceType: filter.ResourceType,
Filters: json.RawMessage(filter.Filters), // Return JSONB as-is
CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339),
UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339),
}
return c.JSON(http.StatusOK, response)
}
// CreateSavedFilter - POST /api/saved-filters
// Supports both JSON (API) and HTML (HTMX) responses
func (h *FiltersHandler) CreateSavedFilter(c *echo.Context) error {
+1
View File
@@ -10,6 +10,7 @@ func registerFiltersRoutes(cfg *Config) {
// Saved filters routes
filters := protected.Group("/saved-filters")
filters.GET("", cfg.FiltersHandler.GetSavedFilters)
filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID)
filters.POST("", cfg.FiltersHandler.CreateSavedFilter)
filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter)
filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter)
+13
View File
@@ -31,6 +31,19 @@ func (s *FiltersService) GetSavedFilters(ctx context.Context, userID uuid.UUID,
return filters, nil
}
// GetSavedFilterByID - Retrieve a single saved filter by ID
func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) (database.SavedFilters, error) {
filter, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{
ID: pgtype.UUID{Bytes: filterID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
if err != nil {
return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err)
}
return filter, nil
}
// CreateSavedFilter - Create a new saved filter
func (s *FiltersService) CreateSavedFilter(ctx context.Context, userID uuid.UUID, name string, resourceType string, filters map[string]string) (database.SavedFilters, error) {
// Business logic: Validate filter name uniqueness per user + resource type