package router import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "testing" "github.com/labstack/echo/v4" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/i18n" "github.com/treytartt/honeydue-api/internal/services" ) func TestMain(m *testing.M) { // Initialize i18n so LocalizedMessage returns real translations _ = i18n.Init() os.Exit(m.Run()) } // makeContext creates a fresh echo.Context and response recorder for testing. func makeContext(method, path string) (echo.Context, *httptest.ResponseRecorder) { e := echo.New() req := httptest.NewRequest(method, path, nil) req.Header.Set("Accept-Language", "en") rec := httptest.NewRecorder() c := e.NewContext(req, rec) // Set up i18n localizer on context (same key the middleware uses) if i18n.Bundle != nil { loc := i18n.NewLocalizer("en") c.Set(i18n.LocalizerKey, loc) } return c, rec } // decodeError reads the JSON response into an ErrorResponse. func decodeError(t *testing.T, rec *httptest.ResponseRecorder) responses.ErrorResponse { t.Helper() var resp responses.ErrorResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode error response: %v\nbody: %s", err, rec.Body.String()) } return resp } // --- AppError branch --- func TestErrorHandler_AppError_NotFound(t *testing.T) { c, rec := makeContext(http.MethodGet, "/tasks/1") err := apperrors.NotFound("error.task_not_found") customHTTPErrorHandler(err, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } resp := decodeError(t, rec) if resp.Error == "" { t.Error("expected non-empty error message") } } func TestErrorHandler_AppError_Forbidden(t *testing.T) { c, rec := makeContext(http.MethodGet, "/res/1") err := apperrors.Forbidden("error.task_access_denied") customHTTPErrorHandler(err, c) if rec.Code != http.StatusForbidden { t.Errorf("code = %d, want %d", rec.Code, http.StatusForbidden) } } func TestErrorHandler_AppError_BadRequest(t *testing.T) { c, rec := makeContext(http.MethodPost, "/tasks") err := apperrors.BadRequest("error.task_already_cancelled") customHTTPErrorHandler(err, c) if rec.Code != http.StatusBadRequest { t.Errorf("code = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestErrorHandler_AppError_WithWrappedErr(t *testing.T) { c, rec := makeContext(http.MethodGet, "/x") err := apperrors.Internal(fmt.Errorf("db conn failed")) customHTTPErrorHandler(err, c) if rec.Code != http.StatusInternalServerError { t.Errorf("code = %d, want %d", rec.Code, http.StatusInternalServerError) } } func TestErrorHandler_AppError_I18nFallback(t *testing.T) { c, rec := makeContext(http.MethodGet, "/x") err := apperrors.NotFound("nonexistent.i18n.key").WithMessage("fallback msg") customHTTPErrorHandler(err, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } resp := decodeError(t, rec) if resp.Error != "fallback msg" { t.Errorf("error = %q, want %q", resp.Error, "fallback msg") } } // --- Echo HTTPError branch --- func TestErrorHandler_EchoHTTPError_404(t *testing.T) { c, rec := makeContext(http.MethodGet, "/nope") err := echo.NewHTTPError(http.StatusNotFound, "not found") customHTTPErrorHandler(err, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } resp := decodeError(t, rec) if resp.Error != "not found" { t.Errorf("error = %q, want %q", resp.Error, "not found") } } func TestErrorHandler_EchoHTTPError_405(t *testing.T) { c, rec := makeContext(http.MethodGet, "/x") err := echo.ErrMethodNotAllowed customHTTPErrorHandler(err, c) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("code = %d, want %d", rec.Code, http.StatusMethodNotAllowed) } } // --- Sentinel error branch --- func TestErrorHandler_ErrTaskNotFound(t *testing.T) { c, rec := makeContext(http.MethodGet, "/t") customHTTPErrorHandler(services.ErrTaskNotFound, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } } func TestErrorHandler_ErrCompletionNotFound(t *testing.T) { c, rec := makeContext(http.MethodGet, "/t") customHTTPErrorHandler(services.ErrCompletionNotFound, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } } func TestErrorHandler_ErrTaskAccessDenied(t *testing.T) { c, rec := makeContext(http.MethodGet, "/t") customHTTPErrorHandler(services.ErrTaskAccessDenied, c) if rec.Code != http.StatusForbidden { t.Errorf("code = %d, want %d", rec.Code, http.StatusForbidden) } } func TestErrorHandler_ErrTaskAlreadyCancelled(t *testing.T) { c, rec := makeContext(http.MethodPost, "/t") customHTTPErrorHandler(services.ErrTaskAlreadyCancelled, c) if rec.Code != http.StatusBadRequest { t.Errorf("code = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestErrorHandler_ErrTaskAlreadyArchived(t *testing.T) { c, rec := makeContext(http.MethodPost, "/t") customHTTPErrorHandler(services.ErrTaskAlreadyArchived, c) if rec.Code != http.StatusBadRequest { t.Errorf("code = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestErrorHandler_ErrResidenceNotFound(t *testing.T) { c, rec := makeContext(http.MethodGet, "/r") customHTTPErrorHandler(services.ErrResidenceNotFound, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } } func TestErrorHandler_ErrResidenceAccessDenied(t *testing.T) { c, rec := makeContext(http.MethodGet, "/r") customHTTPErrorHandler(services.ErrResidenceAccessDenied, c) if rec.Code != http.StatusForbidden { t.Errorf("code = %d, want %d", rec.Code, http.StatusForbidden) } } func TestErrorHandler_ErrNotResidenceOwner(t *testing.T) { c, rec := makeContext(http.MethodDelete, "/r") customHTTPErrorHandler(services.ErrNotResidenceOwner, c) if rec.Code != http.StatusForbidden { t.Errorf("code = %d, want %d", rec.Code, http.StatusForbidden) } } func TestErrorHandler_ErrPropertiesLimitReached(t *testing.T) { c, rec := makeContext(http.MethodPost, "/r") customHTTPErrorHandler(services.ErrPropertiesLimitReached, c) if rec.Code != http.StatusForbidden { t.Errorf("code = %d, want %d", rec.Code, http.StatusForbidden) } } func TestErrorHandler_ErrCannotRemoveOwner(t *testing.T) { c, rec := makeContext(http.MethodDelete, "/r") customHTTPErrorHandler(services.ErrCannotRemoveOwner, c) if rec.Code != http.StatusBadRequest { t.Errorf("code = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestErrorHandler_ErrShareCodeExpired(t *testing.T) { c, rec := makeContext(http.MethodPost, "/r") customHTTPErrorHandler(services.ErrShareCodeExpired, c) if rec.Code != http.StatusBadRequest { t.Errorf("code = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestErrorHandler_ErrShareCodeInvalid(t *testing.T) { c, rec := makeContext(http.MethodPost, "/r") customHTTPErrorHandler(services.ErrShareCodeInvalid, c) if rec.Code != http.StatusNotFound { t.Errorf("code = %d, want %d", rec.Code, http.StatusNotFound) } } func TestErrorHandler_ErrUserAlreadyMember(t *testing.T) { c, rec := makeContext(http.MethodPost, "/r") customHTTPErrorHandler(services.ErrUserAlreadyMember, c) if rec.Code != http.StatusConflict { t.Errorf("code = %d, want %d", rec.Code, http.StatusConflict) } } // --- Default branch --- func TestErrorHandler_UnknownError(t *testing.T) { c, rec := makeContext(http.MethodGet, "/x") customHTTPErrorHandler(errors.New("random unexpected error"), c) if rec.Code != http.StatusInternalServerError { t.Errorf("code = %d, want %d", rec.Code, http.StatusInternalServerError) } } // --- Committed response --- func TestErrorHandler_CommittedResponse_Noop(t *testing.T) { c, rec := makeContext(http.MethodGet, "/x") // Write something to commit the response c.JSON(http.StatusOK, map[string]string{"ok": "true"}) // Capture the body after first write bodyBefore := rec.Body.String() // Now call error handler — it should be a no-op customHTTPErrorHandler(errors.New("should be ignored"), c) bodyAfter := rec.Body.String() if bodyBefore != bodyAfter { t.Errorf("body changed after committed response:\nbefore: %s\nafter: %s", bodyBefore, bodyAfter) } }