Files
httpx/server/middleware_test.go
Aleksey Shakhmatov cea75d198b Add production-ready HTTP server package with routing, health checks, and middleware
Introduces server/ sub-package as the server-side companion to the existing Client.
Includes Router (over http.ServeMux with groups and mounting), graceful shutdown with
signal handling, health endpoints (/healthz, /readyz), and built-in middlewares
(RequestID, Recovery, Logging). Zero external dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:41:54 +03:00

235 lines
6.3 KiB
Go

package server_test
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.codelab.vc/pkg/httpx/server"
)
func TestChain(t *testing.T) {
t.Run("applies middlewares in correct order", func(t *testing.T) {
var order []string
mwA := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
order = append(order, "A-before")
next.ServeHTTP(w, r)
order = append(order, "A-after")
})
}
mwB := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
order = append(order, "B-before")
next.ServeHTTP(w, r)
order = append(order, "B-after")
})
}
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
order = append(order, "handler")
w.WriteHeader(http.StatusOK)
})
chained := server.Chain(mwA, mwB)(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
chained.ServeHTTP(w, req)
expected := []string{"A-before", "B-before", "handler", "B-after", "A-after"}
if len(order) != len(expected) {
t.Fatalf("got %v, want %v", order, expected)
}
for i, v := range expected {
if order[i] != v {
t.Fatalf("order[%d] = %q, want %q", i, order[i], v)
}
}
})
t.Run("empty chain returns handler unchanged", func(t *testing.T) {
called := false
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
chained := server.Chain()(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
chained.ServeHTTP(w, req)
if !called {
t.Fatal("handler was not called")
}
})
}
func TestRequestID(t *testing.T) {
t.Run("generates ID when not present", func(t *testing.T) {
var gotID string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotID = server.RequestIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
mw := server.RequestID()(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
mw.ServeHTTP(w, req)
if gotID == "" {
t.Fatal("expected request ID in context, got empty")
}
if w.Header().Get("X-Request-Id") != gotID {
t.Fatalf("response header %q != context ID %q", w.Header().Get("X-Request-Id"), gotID)
}
// UUID v4 format: 8-4-4-4-12 hex chars.
if len(gotID) != 36 {
t.Fatalf("expected UUID length 36, got %d: %q", len(gotID), gotID)
}
})
t.Run("preserves existing ID", func(t *testing.T) {
var gotID string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotID = server.RequestIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
mw := server.RequestID()(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Request-Id", "custom-123")
mw.ServeHTTP(w, req)
if gotID != "custom-123" {
t.Fatalf("got ID %q, want %q", gotID, "custom-123")
}
})
t.Run("context without ID returns empty", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if id := server.RequestIDFromContext(req.Context()); id != "" {
t.Fatalf("expected empty, got %q", id)
}
})
}
func TestRecovery(t *testing.T) {
t.Run("recovers from panic and returns 500", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
panic("something went wrong")
})
mw := server.Recovery()(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
mw.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("got status %d, want %d", w.Code, http.StatusInternalServerError)
}
})
t.Run("logs panic with logger", func(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
panic("boom")
})
mw := server.Recovery(server.WithRecoveryLogger(logger))(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
mw.ServeHTTP(w, req)
if !strings.Contains(buf.String(), "panic recovered") {
t.Fatalf("expected log to contain 'panic recovered', got %q", buf.String())
}
if !strings.Contains(buf.String(), "boom") {
t.Fatalf("expected log to contain 'boom', got %q", buf.String())
}
})
t.Run("passes through without panic", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mw := server.Recovery()(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
mw.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", w.Code, http.StatusOK)
}
})
}
func TestLogging(t *testing.T) {
t.Run("logs request details", func(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
mw := server.Logging(logger)(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
mw.ServeHTTP(w, req)
logOutput := buf.String()
if !strings.Contains(logOutput, "request completed") {
t.Fatalf("expected 'request completed' in log, got %q", logOutput)
}
if !strings.Contains(logOutput, "POST") {
t.Fatalf("expected method in log, got %q", logOutput)
}
if !strings.Contains(logOutput, "/api/users") {
t.Fatalf("expected path in log, got %q", logOutput)
}
if !strings.Contains(logOutput, "status=201") {
t.Fatalf("expected status=201 in log, got %q", logOutput)
}
})
t.Run("logs error level for 5xx", func(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
})
mw := server.Logging(logger)(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
mw.ServeHTTP(w, req)
logOutput := buf.String()
if !strings.Contains(logOutput, "level=ERROR") {
t.Fatalf("expected ERROR level in log, got %q", logOutput)
}
})
}