Ctrl K
ComunidadGuías…

internal/handler/errors_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	}
}