fix: use custom error type for saved filters not found
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.
This commit is contained in:
@@ -103,7 +103,7 @@ func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error {
|
|||||||
// Use service layer (includes business logic + ownership verification)
|
// Use service layer (includes business logic + ownership verification)
|
||||||
filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID)
|
filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID)
|
||||||
if err != nil {
|
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{
|
return c.JSON(http.StatusNotFound, map[string]string{
|
||||||
"error": "filter not found",
|
"error": "filter not found",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Custom errors for saved filters
|
||||||
|
var ErrFilterNotFound = errors.New("filter not found or access denied")
|
||||||
|
|
||||||
type FiltersService struct {
|
type FiltersService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
}
|
}
|
||||||
@@ -38,7 +42,7 @@ func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUI
|
|||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err)
|
return database.SavedFilters{}, ErrFilterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter, nil
|
return filter, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user