From ed50d2427e9f8995e28ed313dd0de1148fd28eec Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Fri, 17 Apr 2026 19:04:23 +0200 Subject: [PATCH] fix: return field-level validation errors on POST /habits Previously all validation failures in CreateHabitHandler returned a generic {"error":"invalid input"}, making it impossible to tell whether type, frequency, specific_days or specific_dates was the problem. Errors are now wrapped with field + i18n key and the HTTP layer replies via respondValidationErrorI18n, matching the pattern already used by auth endpoints. --- internal/application/commands/create_habit.go | 9 ++-- .../application/commands/create_habit_test.go | 50 ++++++++++++++++--- .../infrastructure/http/habit_handlers.go | 5 +- .../http/habit_integration_test.go | 41 +++++++++++++++ 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/internal/application/commands/create_habit.go b/internal/application/commands/create_habit.go index 6b89d43..6aa6090 100644 --- a/internal/application/commands/create_habit.go +++ b/internal/application/commands/create_habit.go @@ -2,6 +2,7 @@ package commands import ( "context" + "fmt" "apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/repositories" @@ -32,19 +33,19 @@ func NewCreateHabitHandler(habitRepo repositories.HabitRepository) *CreateHabitH func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) { if !cmd.Type.IsValid() { - return "", errors.ErrInvalidInput + return "", fmt.Errorf("%w: type: type_invalid", errors.ErrInvalidInput) } if !cmd.Frequency.IsValid() { - return "", errors.ErrInvalidInput + return "", fmt.Errorf("%w: frequency: frequency_invalid", errors.ErrInvalidInput) } if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 { - return "", errors.ErrInvalidInput + return "", fmt.Errorf("%w: specific_days: specific_days_required", errors.ErrInvalidInput) } if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 { - return "", errors.ErrInvalidInput + return "", fmt.Errorf("%w: specific_dates: specific_dates_required", errors.ErrInvalidInput) } habit := entities.NewHabit(cmd.UserID, cmd.Name, cmd.Type, cmd.Frequency, cmd.CarryOver, cmd.IsNegative) diff --git a/internal/application/commands/create_habit_test.go b/internal/application/commands/create_habit_test.go index d2353ea..de7e83c 100644 --- a/internal/application/commands/create_habit_test.go +++ b/internal/application/commands/create_habit_test.go @@ -4,6 +4,8 @@ import ( "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" + stderrors "errors" + "strings" "testing" "time" @@ -110,8 +112,31 @@ func TestCreateHabitHandler_InvalidType(t *testing.T) { _, err := handler.Handle(context.Background(), cmd) - if err != errors.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + if !stderrors.Is(err, errors.ErrInvalidInput) { + t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err) + } + if !strings.Contains(err.Error(), "type: type_invalid") { + t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error()) + } +} + +func TestCreateHabitHandler_EmptyType(t *testing.T) { + mock := &mockHabitRepo{} + handler := NewCreateHabitHandler(mock) + + cmd := CreateHabitCommand{ + UserID: "user-123", + Name: "Exercise", + Frequency: "DAILY", + } + + _, err := handler.Handle(context.Background(), cmd) + + if !stderrors.Is(err, errors.ErrInvalidInput) { + t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err) + } + if !strings.Contains(err.Error(), "type: type_invalid") { + t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error()) } } @@ -128,8 +153,11 @@ func TestCreateHabitHandler_InvalidFrequency(t *testing.T) { _, err := handler.Handle(context.Background(), cmd) - if err != errors.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + if !stderrors.Is(err, errors.ErrInvalidInput) { + t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err) + } + if !strings.Contains(err.Error(), "frequency: frequency_invalid") { + t.Errorf("Expected 'frequency: frequency_invalid' in error, got %q", err.Error()) } } @@ -147,8 +175,11 @@ func TestCreateHabitHandler_WeeklyWithoutSpecificDays(t *testing.T) { _, err := handler.Handle(context.Background(), cmd) - if err != errors.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + if !stderrors.Is(err, errors.ErrInvalidInput) { + t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err) + } + if !strings.Contains(err.Error(), "specific_days: specific_days_required") { + t.Errorf("Expected 'specific_days: specific_days_required' in error, got %q", err.Error()) } } @@ -166,8 +197,11 @@ func TestCreateHabitHandler_MonthlyWithoutSpecificDates(t *testing.T) { _, err := handler.Handle(context.Background(), cmd) - if err != errors.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + if !stderrors.Is(err, errors.ErrInvalidInput) { + t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err) + } + if !strings.Contains(err.Error(), "specific_dates: specific_dates_required") { + t.Errorf("Expected 'specific_dates: specific_dates_required' in error, got %q", err.Error()) } } diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index fb820ea..c189c12 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -2,6 +2,7 @@ package http import ( "encoding/json" + stderrors "errors" "net/http" "strconv" "strings" @@ -97,8 +98,8 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { habitID, err := h.createHandler.Handle(r.Context(), cmd) if err != nil { - if err == errors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, err.Error()) + if stderrors.Is(err, errors.ErrInvalidInput) { + respondValidationErrorI18n(w, r, h.translator, err) return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit") diff --git a/internal/infrastructure/http/habit_integration_test.go b/internal/infrastructure/http/habit_integration_test.go index c7add21..c95112d 100644 --- a/internal/infrastructure/http/habit_integration_test.go +++ b/internal/infrastructure/http/habit_integration_test.go @@ -123,6 +123,47 @@ func TestHabitCRUDFlow(t *testing.T) { } }) + t.Run("Create habit with missing type returns field-level error", func(t *testing.T) { + token := registerAndLogin(t, *ts.Router, "validationuser@example.com", "Password123!") + + reqBody := map[string]any{"name": "No Type"} + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + var resp ValidationErrorResponse + decodeResponse(t, rr, &resp) + if resp.Field != "type" { + t.Errorf("Expected field 'type', got %q. Body: %s", resp.Field, rr.Body.String()) + } + if resp.Error == "" { + t.Errorf("Expected non-empty translated error message. Body: %s", rr.Body.String()) + } + }) + + t.Run("Create weekly habit without specific_days returns field-level error", func(t *testing.T) { + token := registerAndLogin(t, *ts.Router, "weeklyuser@example.com", "Password123!") + + reqBody := CreateHabitRequest{ + Name: "Cut nails", + Type: "BOOLEAN", + Frequency: "WEEKLY", + } + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + var resp ValidationErrorResponse + decodeResponse(t, rr, &resp) + if resp.Field != "specific_days" { + t.Errorf("Expected field 'specific_days', got %q. Body: %s", resp.Field, rr.Body.String()) + } + }) + t.Run("Access other user's habit", func(t *testing.T) { otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")