package handler import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" "github.com/johnzastrow/actalog/internal/domain" "github.com/johnzastrow/actalog/internal/service" ) func TestHTTPError(t *testing.T) { t.Run("Error with message only", func(t *testing.T) { err := NewHTTPError(http.StatusNotFound, "resource not found", nil) if err.Error() != "resource not found" { t.Errorf("Error() = %q, want %q", err.Error(), "resource not found") } if err.Status != http.StatusNotFound { t.Errorf("Status = %d, want %d", err.Status, http.StatusNotFound) } }) t.Run("Error with wrapped error", func(t *testing.T) { wrapped := errors.New("original error") err := NewHTTPError(http.StatusBadRequest, "bad request", wrapped) if err.Error() != "original error" { t.Errorf("Error() = %q, want %q", err.Error(), "original error") } if err.Unwrap() != wrapped { t.Error("Unwrap() should return the wrapped error") } }) } func TestMapServiceError(t *testing.T) { tests := []struct { name string err error expectedStatus int expectedKnown bool }{ {"user not found", service.ErrUserNotFound, http.StatusNotFound, true}, {"invalid credentials", service.ErrInvalidCredentials, http.StatusUnauthorized, true}, {"email exists", service.ErrEmailAlreadyExists, http.StatusConflict, true}, {"registration closed", service.ErrRegistrationClosed, http.StatusForbidden, true}, {"movement not found", service.ErrMovementNotFound, http.StatusNotFound, true}, {"movement unauthorized", service.ErrMovementUnauthorized, http.StatusForbidden, true}, {"wod not found", service.ErrWODNotFound, http.StatusNotFound, true}, {"wod duplicate name", service.ErrWODDuplicateName, http.StatusConflict, true}, {"workout not found", service.ErrWorkoutNotFound, http.StatusNotFound, true}, {"user workout not found", service.ErrUserWorkoutNotFound, http.StatusNotFound, true}, {"org not found", service.ErrOrganizationNotFound, http.StatusNotFound, true}, {"subscription not found", service.ErrSubscriptionNotFound, http.StatusNotFound, true}, {"unknown error", errors.New("unknown error"), http.StatusInternalServerError, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, known := MapServiceError(tt.err) if status != tt.expectedStatus { t.Errorf("MapServiceError() status = %d, want %d", status, tt.expectedStatus) } if known != tt.expectedKnown { t.Errorf("MapServiceError() known = %v, want %v", known, tt.expectedKnown) } }) } } func TestWriteError(t *testing.T) { t.Run("known service error", func(t *testing.T) { w := httptest.NewRecorder() WriteError(w, service.ErrUserNotFound) if w.Code != http.StatusNotFound { t.Errorf("Status = %d, want %d", w.Code, http.StatusNotFound) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp.Message != "user not found" { t.Errorf("Message = %q, want %q", resp.Message, "user not found") } }) t.Run("HTTPError", func(t *testing.T) { w := httptest.NewRecorder() httpErr := NewHTTPError(http.StatusTeapot, "I'm a teapot", nil) WriteError(w, httpErr) if w.Code != http.StatusTeapot { t.Errorf("Status = %d, want %d", w.Code, http.StatusTeapot) } }) t.Run("unknown error", func(t *testing.T) { w := httptest.NewRecorder() WriteError(w, errors.New("some random error")) if w.Code != http.StatusInternalServerError { t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError) } }) } func TestWriteErrorWithStatus(t *testing.T) { w := httptest.NewRecorder() WriteErrorWithStatus(w, http.StatusBadRequest, "invalid input") if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, want %d", w.Code, http.StatusBadRequest) } if w.Header().Get("Content-Type") != "application/json" { t.Error("Content-Type should be application/json") } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp.Message != "invalid input" { t.Errorf("Message = %q, want %q", resp.Message, "invalid input") } } func TestWriteErrorWithDetail(t *testing.T) { w := httptest.NewRecorder() WriteErrorWithDetail(w, http.StatusBadRequest, "validation failed", "email is required") if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, want %d", w.Code, http.StatusBadRequest) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp.Message != "validation failed" { t.Errorf("Message = %q, want %q", resp.Message, "validation failed") } if resp.Error != "email is required" { t.Errorf("Error = %q, want %q", resp.Error, "email is required") } } func TestWriteJSON(t *testing.T) { w := httptest.NewRecorder() data := map[string]string{"status": "ok"} WriteJSON(w, http.StatusOK, data) if w.Code != http.StatusOK { t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) } if w.Header().Get("Content-Type") != "application/json" { t.Error("Content-Type should be application/json") } var resp map[string]string if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp["status"] != "ok" { t.Errorf("status = %q, want %q", resp["status"], "ok") } } func TestHandleServiceError(t *testing.T) { t.Run("nil error returns false", func(t *testing.T) { w := httptest.NewRecorder() handled := HandleServiceError(w, nil, "test") if handled { t.Error("HandleServiceError(nil) should return false") } if w.Code != http.StatusOK { t.Errorf("Status should not be set for nil error, got %d", w.Code) } }) t.Run("known error", func(t *testing.T) { w := httptest.NewRecorder() handled := HandleServiceError(w, service.ErrUserNotFound, "getting user") if !handled { t.Error("HandleServiceError should return true for non-nil error") } if w.Code != http.StatusNotFound { t.Errorf("Status = %d, want %d", w.Code, http.StatusNotFound) } }) t.Run("unknown error hides details", func(t *testing.T) { w := httptest.NewRecorder() handled := HandleServiceError(w, errors.New("internal database error"), "processing") if !handled { t.Error("HandleServiceError should return true for non-nil error") } if w.Code != http.StatusInternalServerError { t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } // Should not expose internal error details if resp.Message == "internal database error" { t.Error("Should not expose internal error details") } }) } // TestWriteError_MapsWrappedErrUserNotFoundTo404 verifies that a wrapped // service.ErrUserNotFound (as produced by AdminUserService) is still correctly // resolved to HTTP 404 via errors.Is semantics in MapServiceError. func TestWriteError_MapsWrappedErrUserNotFoundTo404(t *testing.T) { wrapped := fmt.Errorf("ensureNotProtected: user 999: %w", service.ErrUserNotFound) w := httptest.NewRecorder() WriteError(w, wrapped) if w.Code != http.StatusNotFound { t.Errorf("Status = %d, want %d (wrapped ErrUserNotFound)", w.Code, http.StatusNotFound) } } // TestWriteError_MapsErrProtectedUserTo403 verifies that domain.ErrProtectedUser // is mapped to HTTP 403 with a structured body containing the machine-readable // error code "protected_user" and a non-empty documentation_url. func TestWriteError_MapsErrProtectedUserTo403(t *testing.T) { w := httptest.NewRecorder() WriteError(w, domain.ErrProtectedUser) if w.Code != http.StatusForbidden { t.Errorf("Status = %d, want %d", w.Code, http.StatusForbidden) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp.Error != "protected_user" { t.Errorf("Error = %q, want %q", resp.Error, "protected_user") } if resp.DocumentationURL == "" { t.Error("DocumentationURL must not be empty for ErrProtectedUser") } if resp.Message == "" { t.Error("Message must not be empty for ErrProtectedUser") } } // TestWriteError_MapsErrProtectedUserWrappedTo403 verifies that a wrapped // domain.ErrProtectedUser is still correctly mapped via errors.Is semantics. func TestWriteError_MapsErrProtectedUserWrappedTo403(t *testing.T) { wrapped := errors.Join(errors.New("outer context"), domain.ErrProtectedUser) w := httptest.NewRecorder() WriteError(w, wrapped) if w.Code != http.StatusForbidden { t.Errorf("Status = %d, want %d (wrapped ErrProtectedUser)", w.Code, http.StatusForbidden) } } // TestWriteError_MapsErrConflictTo409 verifies that domain.ErrConflict is // mapped to HTTP 409 with a structured body containing error code "conflict". func TestWriteError_MapsErrConflictTo409(t *testing.T) { w := httptest.NewRecorder() WriteError(w, domain.ErrConflict) if w.Code != http.StatusConflict { t.Errorf("Status = %d, want %d", w.Code, http.StatusConflict) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } if resp.Error != "conflict" { t.Errorf("Error = %q, want %q", resp.Error, "conflict") } if resp.Message == "" { t.Error("Message must not be empty for ErrConflict") } } func TestCommonHTTPErrors(t *testing.T) { tests := []struct { name string err *HTTPError expectedStatus int }{ {"BadRequest", ErrBadRequest, http.StatusBadRequest}, {"Unauthorized", ErrUnauthorized, http.StatusUnauthorized}, {"Forbidden", ErrForbidden, http.StatusForbidden}, {"NotFound", ErrNotFound, http.StatusNotFound}, {"Conflict", ErrConflict, http.StatusConflict}, {"PaymentRequired", ErrPaymentRequired, http.StatusPaymentRequired}, {"InternalServerError", ErrInternalServerError, http.StatusInternalServerError}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.err.Status != tt.expectedStatus { t.Errorf("%s.Status = %d, want %d", tt.name, tt.err.Status, tt.expectedStatus) } }) } } // TestWriteError_MapsInvalidInputErrorTo400 verifies the new validation // branch added for v1.3.0 manual-test-plan bug B: a *domain.InvalidInputError // from a service must surface as HTTP 400 with the message + "invalid_input" // code, not as the generic "an internal error occurred" 500 the user saw. func TestWriteError_MapsInvalidInputErrorTo400(t *testing.T) { w := httptest.NewRecorder() WriteError(w, &domain.InvalidInputError{Field: "birthday", Message: "must be in the past"}) if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, want %d", w.Code, http.StatusBadRequest) } var resp ErrorResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Error != "invalid_input" { t.Errorf("Error = %q, want %q", resp.Error, "invalid_input") } if resp.Message != "birthday: must be in the past" { t.Errorf("Message = %q, want %q", resp.Message, "birthday: must be in the past") } } // TestWriteError_MapsInvalidInputError_WithoutField verifies that an // InvalidInputError with no Field set surfaces just the Message. func TestWriteError_MapsInvalidInputError_WithoutField(t *testing.T) { w := httptest.NewRecorder() WriteError(w, &domain.InvalidInputError{Message: "input is malformed"}) if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, want %d", w.Code, http.StatusBadRequest) } var resp ErrorResponse _ = json.NewDecoder(w.Body).Decode(&resp) if resp.Message != "input is malformed" { t.Errorf("Message = %q, want %q", resp.Message, "input is malformed") } } // TestWriteError_MapsWrappedInvalidInputErrorTo400 confirms errors.As reaches // the typed value through fmt.Errorf wrapping. func TestWriteError_MapsWrappedInvalidInputErrorTo400(t *testing.T) { inner := &domain.InvalidInputError{Field: "email", Message: "not parseable"} wrapped := errors.Join(errors.New("controller context"), inner) w := httptest.NewRecorder() WriteError(w, wrapped) if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, want %d (wrapped InvalidInputError)", w.Code, http.StatusBadRequest) } }