diff --git a/cmd/server/tests/filters_test.go b/cmd/server/tests/filters_test.go index 6449cf1..e326096 100644 --- a/cmd/server/tests/filters_test.go +++ b/cmd/server/tests/filters_test.go @@ -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) + }) } diff --git a/internal/handlers/filters.go b/internal/handlers/filters.go index 81ce398..afe0516 100644 --- a/internal/handlers/filters.go +++ b/internal/handlers/filters.go @@ -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 { diff --git a/internal/router/filters.go b/internal/router/filters.go index 22a72ec..343a2a7 100644 --- a/internal/router/filters.go +++ b/internal/router/filters.go @@ -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) diff --git a/internal/services/filters.go b/internal/services/filters.go index 4aff428..2ac8bbe 100644 --- a/internal/services/filters.go +++ b/internal/services/filters.go @@ -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