From a71baa49cdf7989073a2c32ba1d841e80850102a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 22:58:55 +0200 Subject: [PATCH] test: regression coverage for error mapping and client error extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handlers: pgx.ErrNoRows→404, InputError→400 with message preserved, pgconn 23505→409 without constraint/SQLSTATE leak, unknown→500 - services: export NewInputError so error construction is testable - frontend: vitest for errorMessage helper — server reason preferred, status text and fallbacks verified Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/handlers/http_test.go | 47 +++++++++++++++++++ .../internal/services/platform_service.go | 2 + .../src/lib/__tests__/errorMessage.test.ts | 29 ++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 apps/frontend/src/lib/__tests__/errorMessage.test.ts diff --git a/apps/backend/internal/handlers/http_test.go b/apps/backend/internal/handlers/http_test.go index 8e44012..7cbc865 100644 --- a/apps/backend/internal/handlers/http_test.go +++ b/apps/backend/internal/handlers/http_test.go @@ -4,9 +4,14 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/tdvorak/primora/apps/backend/internal/services" ) func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) { @@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) { t.Fatalf("unexpected status: %d", recorder.Code) } }) + + t.Run("maps missing rows to not found", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, pgx.ErrNoRows) + if recorder.Code != http.StatusNotFound { + t.Fatalf("unexpected status: %d", recorder.Code) + } + }) + + t.Run("maps InputError to bad request with message", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, services.NewInputError("webhook url must be https")) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "webhook url must be https") { + t.Fatalf("expected reason in body, got %s", recorder.Body.String()) + } + }) + + t.Run("maps unique violation to conflict without leaking", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""}) + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") { + t.Fatalf("raw constraint leaked: %s", recorder.Body.String()) + } + }) + + t.Run("maps unknown pg error to internal error", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"}) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", recorder.Code) + } + }) } diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 41f3c79..979c700 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -1534,6 +1534,8 @@ type InputError struct{ msg string } func (e *InputError) Error() string { return e.msg } +func NewInputError(msg string) error { return &InputError{msg: msg} } + func inputErrorf(format string, args ...any) error { return &InputError{msg: fmt.Sprintf(format, args...)} } diff --git a/apps/frontend/src/lib/__tests__/errorMessage.test.ts b/apps/frontend/src/lib/__tests__/errorMessage.test.ts new file mode 100644 index 0000000..0b7b50f --- /dev/null +++ b/apps/frontend/src/lib/__tests__/errorMessage.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { errorMessage } from "../api"; + +describe("errorMessage", () => { + it("prefers the server's reason over the HTTP status text", () => { + const apiError = { + message: "Bad Request", + body: { error: { message: "webhook url must be https (http allowed only for private/self-hosted targets)" } }, + }; + expect(errorMessage(apiError)).toBe( + "webhook url must be https (http allowed only for private/self-hosted targets)", + ); + }); + + it("falls back to error.message when no body reason exists", () => { + expect(errorMessage(new Error("network down"))).toBe("network down"); + }); + + it("uses the fallback for non-error values", () => { + expect(errorMessage(null, "Nope")).toBe("Nope"); + expect(errorMessage(undefined)).toBe("Request failed"); + expect(errorMessage("string failure")).toBe("Request failed"); + }); + + it("ignores empty body and message strings", () => { + expect(errorMessage({ message: "", body: { error: { message: "" } } }, "Empty")).toBe("Empty"); + }); +});