// Package echotest builds echo contexts for handler unit tests so each test // states only what differs: method, target, body, and the occasional header // or path value. package echotest import ( "bytes" "encoding/json" "io" "maps" "net/http" "net/http/httptest" "strings" "testing" "github.com/labstack/echo/v5" ) // Option adjusts the request or context built by Request. type Option func(*settings) type settings struct { headers http.Header pathValues echo.PathValues path string values map[string]any contentType string remoteAddr string ipExtractor echo.IPExtractor wrapWriter func(http.ResponseWriter) http.ResponseWriter } // WithHeader sets a request header. func WithHeader(key, value string) Option { return func(s *settings) { s.headers.Set(key, value) } } // WithPathValue binds a route parameter (c.Param) for the handler. func WithPathValue(name, value string) Option { return func(s *settings) { s.pathValues = append(s.pathValues, echo.PathValue{Name: name, Value: value}) } } // WithPath sets the route path template (c.Path), for handlers that inspect it. func WithPath(path string) Option { return func(s *settings) { s.path = path } } // WithValue stores a context value (c.Set) before the handler runs, for // values middleware would normally provide. func WithValue(key string, value any) Option { return func(s *settings) { s.values[key] = value } } // WithRemoteAddr sets the address of the connection the request arrived on, // for handlers that distinguish the socket peer from a forwarded client. The // value may carry a port ("203.0.113.7:4321") or not. func WithRemoteAddr(addr string) Option { return func(s *settings) { s.remoteAddr = addr } } // WithIPExtractor installs the client address strategy the server would apply, // so c.RealIP() reads forwarding headers the way the deployment does. func WithIPExtractor(extractor echo.IPExtractor) Option { return func(s *settings) { s.ipExtractor = extractor } } // WithContentType overrides the Content-Type set for a non-nil body. func WithContentType(contentType string) Option { return func(s *settings) { s.contentType = contentType } } // WithResponseWriter serves the response through wrap(recorder) instead of the // recorder directly, for a handler whose writes or flushes the test needs to // observe as they happen. The recorder Request returns is still the one the // wrapper writes through, so assertions on the recorded body are unaffected. func WithResponseWriter(wrap func(http.ResponseWriter) http.ResponseWriter) Option { return func(s *settings) { s.wrapWriter = wrap } } // Request builds an echo context and recorder for a handler call. // // body may be nil (no body), a string or []byte (sent verbatim), an io.Reader, // or any other value, which is JSON-encoded. A non-nil body gets a JSON // Content-Type unless WithContentType overrides it. func Request(t testing.TB, method, target string, body any, opts ...Option) (*echo.Context, *httptest.ResponseRecorder) { t.Helper() s := settings{headers: http.Header{}, values: map[string]any{}, contentType: echo.MIMEApplicationJSON} for _, opt := range opts { opt(&s) } req := httptest.NewRequest(method, target, bodyReader(t, body)) if body != nil { req.Header.Set(echo.HeaderContentType, s.contentType) } maps.Copy(req.Header, s.headers) if s.remoteAddr != "" { req.RemoteAddr = s.remoteAddr } rec := httptest.NewRecorder() var writer http.ResponseWriter = rec if s.wrapWriter != nil { writer = s.wrapWriter(rec) } e := echo.New() // Echo's zero value consults forwarding headers; the server pins direct // extraction unless a deployment opts in, so tests start from the same // baseline. e.IPExtractor = echo.ExtractIPDirect() if s.ipExtractor != nil { e.IPExtractor = s.ipExtractor } c := e.NewContext(req, writer) if s.path != "" { c.SetPath(s.path) } if len(s.pathValues) > 0 { c.SetPathValues(s.pathValues) } for key, value := range s.values { c.Set(key, value) } return c, rec } // Get is Request for a GET with no body. func Get(t testing.TB, target string, opts ...Option) (*echo.Context, *httptest.ResponseRecorder) { t.Helper() return Request(t, http.MethodGet, target, nil, opts...) } // Post is Request for a POST with a JSON body. func Post(t testing.TB, target string, body any, opts ...Option) (*echo.Context, *httptest.ResponseRecorder) { t.Helper() return Request(t, http.MethodPost, target, body, opts...) } // Decode unmarshals the recorded JSON response body into T, failing the test // when it is not valid JSON for T. func Decode[T any](t testing.TB, rec *httptest.ResponseRecorder) T { t.Helper() var out T if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("echotest: decode response body as %T: %v\nbody: %s", out, err, rec.Body.String()) } return out } func bodyReader(t testing.TB, body any) io.Reader { t.Helper() switch b := body.(type) { case nil: return nil case string: return strings.NewReader(b) case []byte: return bytes.NewReader(b) case io.Reader: return b default: data, err := json.Marshal(b) if err != nil { t.Fatalf("echotest: marshal request body %T: %v", body, err) } return bytes.NewReader(data) } }