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") }