From c3a98fb067482b356541ad3085f523730d772916 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 21 Mar 2026 23:03:43 -0400 Subject: [PATCH] fix: use custom error type for saved filters not found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix failing test 'GET /api/saved-filters/:id_with_non-existent_filter_returns_404' which was returning HTTP 500 instead of HTTP 404 due to string comparison failure in error handling. Root Cause: - Service wrapped database error: fmt.Errorf("filter not found: %w", err) - Handler checked exact string equality: err.Error() == "filter not found" - Wrapped error message included database error: "filter not found: no rows in result set" - String check failed → returned 500 instead of 404 Solution: Use Go error wrapping with custom error type Changes to internal/services/filters.go: - Add import: "errors" package - Add custom error variable: ErrFilterNotFound - Update GetSavedFilterByID() to return ErrFilterNotFound instead of wrapped error - Error defined at service layer (domain authority) Changes to internal/handlers/filters.go: - Update error check from string comparison to errors.Is(err, services.ErrFilterNotFound) - Uses Go's standard error wrapping pattern - Cleaner, more maintainable, type-safe Architectural Benefits: - ✅ Service layer owns domain errors (filter not found is a filter concept) - ✅ Handlers only translate service errors to HTTP status codes - ✅ Services reusable by any caller (API, WebSocket, CLI) - ✅ Clean dependency direction: Handlers → Services → Database - ✅ Follows Go best practices for error handling Test Results: - GET /api/saved-filters/:id with non-existent filter now returns 404 - Error message: "filter not found" - No information leakage about other users' filters Fixes test failure in TestSavedFilters. --- internal/handlers/filters.go | 2 +- internal/services/filters.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/handlers/filters.go b/internal/handlers/filters.go index afe0516..b550727 100644 --- a/internal/handlers/filters.go +++ b/internal/handlers/filters.go @@ -103,7 +103,7 @@ func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error { // 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" { + if errors.Is(err, services.ErrFilterNotFound) { return c.JSON(http.StatusNotFound, map[string]string{ "error": "filter not found", }) diff --git a/internal/services/filters.go b/internal/services/filters.go index 2ac8bbe..9a35465 100644 --- a/internal/services/filters.go +++ b/internal/services/filters.go @@ -4,12 +4,16 @@ import ( "bookhoard/internal/database" "context" "encoding/json" + "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) +// Custom errors for saved filters +var ErrFilterNotFound = errors.New("filter not found or access denied") + type FiltersService struct { db *database.Queries } @@ -38,7 +42,7 @@ func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUI UserID: pgtype.UUID{Bytes: userID, Valid: true}, }) if err != nil { - return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err) + return database.SavedFilters{}, ErrFilterNotFound } return filter, nil