All checks were successful
CI / test (push) Successful in 30s
Cover edge cases: statusWriter multi-call/default/unwrap, UUID v4 format and uniqueness, non-string panics, recovery body and log attributes, 4xx log level, default status in logging, request ID propagation, server defaults/options/listen-error/multiple-hooks/logger, router groups with empty prefix/inherited middleware/ordering/path params/ isolation, mount trailing slash, health content-type and POST rejection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
269 lines
6.7 KiB
Go
269 lines
6.7 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.codelab.vc/pkg/httpx/server"
|
|
)
|
|
|
|
func TestServerLifecycle(t *testing.T) {
|
|
t.Run("starts and serves requests", func(t *testing.T) {
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("hello"))
|
|
})
|
|
|
|
srv := server.New(handler, server.WithAddr(":0"))
|
|
|
|
// Start in background and wait for addr.
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- srv.ListenAndServe() }()
|
|
|
|
waitForAddr(t, srv)
|
|
|
|
resp, err := http.Get("http://" + srv.Addr())
|
|
if err != nil {
|
|
t.Fatalf("GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != "hello" {
|
|
t.Fatalf("got body %q, want %q", body, "hello")
|
|
}
|
|
|
|
// Shutdown.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
t.Fatalf("shutdown failed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("addr returns empty before start", func(t *testing.T) {
|
|
srv := server.New(http.NotFoundHandler())
|
|
if addr := srv.Addr(); addr != "" {
|
|
t.Fatalf("got addr %q before start, want empty", addr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGracefulShutdown(t *testing.T) {
|
|
t.Run("calls onShutdown hooks", func(t *testing.T) {
|
|
called := false
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
srv := server.New(handler,
|
|
server.WithAddr(":0"),
|
|
server.WithOnShutdown(func() { called = true }),
|
|
)
|
|
|
|
go func() { _ = srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
t.Fatalf("shutdown failed: %v", err)
|
|
}
|
|
|
|
if !called {
|
|
t.Fatal("onShutdown hook was not called")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServerWithMiddleware(t *testing.T) {
|
|
t.Run("applies middleware from options", func(t *testing.T) {
|
|
var called bool
|
|
mw := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
srv := server.New(handler,
|
|
server.WithAddr(":0"),
|
|
server.WithMiddleware(mw),
|
|
)
|
|
|
|
go func() { _ = srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
resp, err := http.Get("http://" + srv.Addr())
|
|
if err != nil {
|
|
t.Fatalf("GET failed: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if !called {
|
|
t.Fatal("middleware was not called")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
})
|
|
}
|
|
|
|
func TestServerDefaults(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.StatusOK)
|
|
})
|
|
|
|
srv := server.New(handler, append(server.Defaults(logger), server.WithAddr(":0"))...)
|
|
|
|
go func() { _ = srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
resp, err := http.Get("http://" + srv.Addr())
|
|
if err != nil {
|
|
t.Fatalf("GET failed: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Defaults includes RequestID middleware, so response should have X-Request-Id.
|
|
if resp.Header.Get("X-Request-Id") == "" {
|
|
t.Fatal("expected X-Request-Id header from Defaults middleware")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
}
|
|
|
|
func TestServerListenError(t *testing.T) {
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Use an invalid address to trigger a listen error.
|
|
srv := server.New(handler, server.WithAddr(":-1"))
|
|
|
|
err := srv.ListenAndServe()
|
|
if err == nil {
|
|
t.Fatal("expected error from invalid address, got nil")
|
|
}
|
|
}
|
|
|
|
func TestServerMultipleOnShutdownHooks(t *testing.T) {
|
|
var calls []int
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
srv := server.New(handler,
|
|
server.WithAddr(":0"),
|
|
server.WithOnShutdown(func() { calls = append(calls, 1) }),
|
|
server.WithOnShutdown(func() { calls = append(calls, 2) }),
|
|
server.WithOnShutdown(func() { calls = append(calls, 3) }),
|
|
)
|
|
|
|
go func() { _ = srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
t.Fatalf("shutdown failed: %v", err)
|
|
}
|
|
|
|
if len(calls) != 3 {
|
|
t.Fatalf("expected 3 hooks called, got %d: %v", len(calls), calls)
|
|
}
|
|
}
|
|
|
|
func TestServerShutdownWithLogger(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.StatusOK)
|
|
})
|
|
|
|
srv := server.New(handler,
|
|
server.WithAddr(":0"),
|
|
server.WithLogger(logger),
|
|
)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
// Send SIGINT to trigger graceful shutdown via ListenAndServe's signal handler.
|
|
// Instead, use Shutdown directly and check log from server start.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
|
|
// The server logs "server started" on ListenAndServe.
|
|
logOutput := buf.String()
|
|
if !strings.Contains(logOutput, "server started") {
|
|
t.Fatalf("expected 'server started' in log, got %q", logOutput)
|
|
}
|
|
}
|
|
|
|
func TestServerOptions(t *testing.T) {
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Verify options don't panic and server starts correctly.
|
|
srv := server.New(handler,
|
|
server.WithAddr(":0"),
|
|
server.WithReadTimeout(5*time.Second),
|
|
server.WithReadHeaderTimeout(3*time.Second),
|
|
server.WithWriteTimeout(10*time.Second),
|
|
server.WithIdleTimeout(60*time.Second),
|
|
server.WithShutdownTimeout(5*time.Second),
|
|
)
|
|
|
|
go func() { _ = srv.ListenAndServe() }()
|
|
waitForAddr(t, srv)
|
|
|
|
resp, err := http.Get("http://" + srv.Addr())
|
|
if err != nil {
|
|
t.Fatalf("GET failed: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("got status %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
}
|
|
|
|
// waitForAddr polls until the server's Addr() is non-empty.
|
|
func waitForAddr(t *testing.T, srv *server.Server) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if srv.Addr() != "" {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("server did not start in time")
|
|
}
|