b074313c01
Seis funciones de servidor HTTP con tests usando httptest: - HTTPJSONResponse: escribe JSON con Content-Type y status code - HTTPErrorResponse: escribe HTTPError como JSON estructurado - HTTPParseBody: decode JSON con limite de bytes y campos estrictos - HTTPLoggerMiddleware: loguea method/path/status/duration a io.Writer - HTTPRouter: crea ServeMux con rutas Go 1.22+ (METHOD /path) - HTTPServe: ListenAndServe con graceful shutdown por contexto 23 tests pasando, solo stdlib net/http. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
325 lines
8.9 KiB
Go
325 lines
8.9 KiB
Go
package infra
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- HTTPJSONResponse ---
|
|
|
|
func TestHTTPJSONResponse(t *testing.T) {
|
|
t.Run("escribe status code correcto", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
HTTPJSONResponse(rec, http.StatusCreated, map[string]string{"ok": "true"})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("got status %d, want 201", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("setea Content-Type application/json", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
HTTPJSONResponse(rec, http.StatusOK, "test")
|
|
ct := rec.Header().Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("got Content-Type=%q, want application/json", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("serializa datos correctamente a JSON", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
data := map[string]int{"count": 42}
|
|
HTTPJSONResponse(rec, http.StatusOK, data)
|
|
|
|
var got map[string]int
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("failed to parse response JSON: %v", err)
|
|
}
|
|
if got["count"] != 42 {
|
|
t.Errorf("got count=%d, want 42", got["count"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- HTTPErrorResponse ---
|
|
|
|
func TestHTTPErrorResponse(t *testing.T) {
|
|
t.Run("escribe el status code del HTTPError", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
HTTPErrorResponse(rec, HTTPError{Status: http.StatusBadRequest, Code: "bad", Message: "err"})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("got status %d, want 400", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("setea Content-Type application/json", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
HTTPErrorResponse(rec, HTTPError{Status: 500, Code: "err", Message: "fail"})
|
|
ct := rec.Header().Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("got Content-Type=%q, want application/json", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("serializa Code y Message en el body JSON", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
HTTPErrorResponse(rec, HTTPError{Status: 422, Code: "validation_error", Message: "campo requerido"})
|
|
|
|
var got HTTPError
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("failed to parse response JSON: %v", err)
|
|
}
|
|
if got.Code != "validation_error" {
|
|
t.Errorf("got Code=%q, want validation_error", got.Code)
|
|
}
|
|
if got.Message != "campo requerido" {
|
|
t.Errorf("got Message=%q, want 'campo requerido'", got.Message)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- HTTPParseBody ---
|
|
|
|
func TestHTTPParseBody(t *testing.T) {
|
|
t.Run("decodifica JSON valido correctamente", func(t *testing.T) {
|
|
body := `{"name":"Lucas","age":30}`
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
var dst struct {
|
|
Name string `json:"name"`
|
|
Age int `json:"age"`
|
|
}
|
|
err := HTTPParseBody(req, &dst, 1<<20)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if dst.Name != "Lucas" || dst.Age != 30 {
|
|
t.Errorf("got {%q, %d}, want {Lucas, 30}", dst.Name, dst.Age)
|
|
}
|
|
})
|
|
|
|
t.Run("retorna error si el body supera maxBytes", func(t *testing.T) {
|
|
body := strings.Repeat("x", 100)
|
|
payload := `{"data":"` + body + `"}`
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(payload))
|
|
|
|
var dst struct {
|
|
Data string `json:"data"`
|
|
}
|
|
err := HTTPParseBody(req, &dst, 10) // limite de 10 bytes
|
|
if err == nil {
|
|
t.Error("expected error for oversized body, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("retorna error si el JSON es invalido", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(`{invalid json`))
|
|
var dst map[string]any
|
|
err := HTTPParseBody(req, &dst, 1<<20)
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("retorna error si hay campos desconocidos", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","unknown":"field"}`))
|
|
var dst struct {
|
|
Name string `json:"name"`
|
|
}
|
|
err := HTTPParseBody(req, &dst, 1<<20)
|
|
if err == nil {
|
|
t.Error("expected error for unknown fields, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- HTTPLoggerMiddleware ---
|
|
|
|
func TestHTTPLoggerMiddleware(t *testing.T) {
|
|
t.Run("loguea metodo y path del request", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
mw := HTTPLoggerMiddleware(&buf)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
log := buf.String()
|
|
if !strings.Contains(log, "GET") {
|
|
t.Errorf("log does not contain method GET: %q", log)
|
|
}
|
|
if !strings.Contains(log, "/api/test") {
|
|
t.Errorf("log does not contain path /api/test: %q", log)
|
|
}
|
|
})
|
|
|
|
t.Run("loguea el status code de la respuesta", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
mw := HTTPLoggerMiddleware(&buf)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/missing", nil)
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
log := buf.String()
|
|
if !strings.Contains(log, "404") {
|
|
t.Errorf("log does not contain status 404: %q", log)
|
|
}
|
|
})
|
|
|
|
t.Run("loguea la duracion en milisegundos", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
mw := HTTPLoggerMiddleware(&buf)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
log := buf.String()
|
|
if !strings.Contains(log, "ms") {
|
|
t.Errorf("log does not contain duration in ms: %q", log)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- HTTPRouter ---
|
|
|
|
func TestHTTPRouter(t *testing.T) {
|
|
t.Run("registra una ruta y responde correctamente", func(t *testing.T) {
|
|
mux := HTTPRouter([]Route{
|
|
{
|
|
Method: "GET",
|
|
Path: "/api/ping",
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("pong"))
|
|
}),
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/api/ping", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got status %d, want 200", rec.Code)
|
|
}
|
|
if rec.Body.String() != "pong" {
|
|
t.Errorf("got body %q, want pong", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("registra multiples rutas en el mismo mux", func(t *testing.T) {
|
|
mux := HTTPRouter([]Route{
|
|
{Method: "GET", Path: "/a", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("a"))
|
|
})},
|
|
{Method: "GET", Path: "/b", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("b"))
|
|
})},
|
|
})
|
|
|
|
recA := httptest.NewRecorder()
|
|
mux.ServeHTTP(recA, httptest.NewRequest("GET", "/a", nil))
|
|
if recA.Body.String() != "a" {
|
|
t.Errorf("route /a: got %q, want a", recA.Body.String())
|
|
}
|
|
|
|
recB := httptest.NewRecorder()
|
|
mux.ServeHTTP(recB, httptest.NewRequest("GET", "/b", nil))
|
|
if recB.Body.String() != "b" {
|
|
t.Errorf("route /b: got %q, want b", recB.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("ruta no registrada retorna 404", func(t *testing.T) {
|
|
mux := HTTPRouter([]Route{
|
|
{Method: "GET", Path: "/known", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/unknown", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got status %d, want 404", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- HTTPServe ---
|
|
|
|
func TestHTTPServe(t *testing.T) {
|
|
t.Run("servidor arranca y responde peticiones", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
mux := HTTPRouter([]Route{
|
|
{Method: "GET", Path: "/ping", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("pong"))
|
|
})},
|
|
})
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- HTTPServe("127.0.0.1:0", mux, ctx)
|
|
}()
|
|
|
|
// Cancelar el contexto rapidamente — el servidor deberia cerrarse limpiamente
|
|
time.Sleep(10 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Errorf("unexpected error from HTTPServe: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("HTTPServe did not stop after context cancel")
|
|
}
|
|
})
|
|
|
|
t.Run("graceful shutdown al cancelar contexto", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- HTTPServe("127.0.0.1:0", handler, ctx)
|
|
}()
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("shutdown error: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("server did not shutdown within 5 seconds")
|
|
}
|
|
})
|
|
}
|