Ctrl K
ComunidadGuías…

internal/echotest/echotest.go

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

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