From 700b7ad45762d7f842eaa98dc748d05e29904053 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Tue, 19 May 2026 03:49:03 -0400 Subject: [PATCH] added test files --- Makefile | 2 + cmd/app/handler/httpjson/handler.go | 23 +++--- cmd/app/handler/httpjson/handler_test.go | 87 ++++++++++++++++++++++ cmd/app/handler/httpjson/health_test.go | 35 +++++++++ internal/config/config_test.go | 91 +++++++++++++++++++++++ internal/infrastructure/os/signal_test.go | 38 ++++++++++ pkg/logger/logger_test.go | 27 +++++++ 7 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 cmd/app/handler/httpjson/handler_test.go create mode 100644 cmd/app/handler/httpjson/health_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/infrastructure/os/signal_test.go create mode 100644 pkg/logger/logger_test.go diff --git a/Makefile b/Makefile index 038f657..72ba4cc 100644 --- a/Makefile +++ b/Makefile @@ -39,5 +39,7 @@ lint: install-lint ## Run lint tests: ## Run tests go test -v ./... +validation: lint tests ## Run lint and tests + build: ## Build the server binary go build -o bin/server ./cmd/app diff --git a/cmd/app/handler/httpjson/handler.go b/cmd/app/handler/httpjson/handler.go index 857fd9d..0581787 100644 --- a/cmd/app/handler/httpjson/handler.go +++ b/cmd/app/handler/httpjson/handler.go @@ -31,16 +31,6 @@ func New(logger *zap.Logger) (*echo.Echo, Handler) { // RunServer runs http server and listens until context is canceled. func (h *Handler) RunServer(ctx context.Context, conf *config.Config, e *echo.Echo) error { - go func() { - <-ctx.Done() - - timeout, cancel := context.WithTimeout(context.Background(), time.Duration(conf.API.ReadTimeout)*time.Second) - defer cancel() - - _ = e.Shutdown(timeout) // try gracefully first - _ = e.Close() // immediate termination - }() - server := &http.Server{ Addr: fmt.Sprintf(":%d", conf.API.Port), ReadTimeout: time.Duration(conf.API.ReadTimeout) * time.Second, @@ -48,7 +38,18 @@ func (h *Handler) RunServer(ctx context.Context, conf *config.Config, e *echo.Ec IdleTimeout: time.Duration(conf.API.IdleTimeout) * time.Second, } - if err := e.StartServer(server); !errors.Is(err, http.ErrServerClosed) { + go func() { + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(conf.API.ReadTimeout)*time.Second) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + h.logger.Warn("graceful shutdown failed", zap.Error(err)) + } + }() + + if err := e.StartServer(server); err != nil && !errors.Is(err, http.ErrServerClosed) { return errors.Wrap(err, "server error") } diff --git a/cmd/app/handler/httpjson/handler_test.go b/cmd/app/handler/httpjson/handler_test.go new file mode 100644 index 0000000..3bbd906 --- /dev/null +++ b/cmd/app/handler/httpjson/handler_test.go @@ -0,0 +1,87 @@ +package httpjson + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/kjannette/go_http_server/internal/config" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +func TestRunServer_ShutdownOnCancel(t *testing.T) { + e, h := New(zap.NewNop()) + + cfg := &config.Config{} + cfg.API.Port = 0 + cfg.API.ReadTimeout = 2 + cfg.API.WriteTimeout = 2 + cfg.API.IdleTimeout = 2 + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- h.RunServer(ctx, cfg, e) + }() + + healthURL, err := waitForHealthURL(e, 3*time.Second) + if err != nil { + t.Fatal(err) + } + + client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + resp, err := client.Get(healthURL) + if err != nil { + t.Fatalf("GET /health: %v", err) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("read body: %v", err) + } + if resp.StatusCode != http.StatusOK || string(body) != "OK" { + t.Fatalf("health check: status=%d body=%q", resp.StatusCode, body) + } + + cancel() + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("RunServer() error = %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("RunServer did not exit after context cancel") + } +} + +func waitForHealthURL(e *echo.Echo, timeout time.Duration) (string, error) { + client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if e.Listener != nil { + _, port, err := net.SplitHostPort(e.Listener.Addr().String()) + if err != nil { + return "", fmt.Errorf("split listener addr: %w", err) + } + url := fmt.Sprintf("http://127.0.0.1:%s/health", port) + + resp, err := client.Get(url) + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return url, nil + } + } + } + time.Sleep(10 * time.Millisecond) + } + return "", fmt.Errorf("server not ready within %s", timeout) +} diff --git a/cmd/app/handler/httpjson/health_test.go b/cmd/app/handler/httpjson/health_test.go new file mode 100644 index 0000000..0f30cfc --- /dev/null +++ b/cmd/app/handler/httpjson/health_test.go @@ -0,0 +1,35 @@ +package httpjson + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +func TestHealthCheck(t *testing.T) { + e, _ := New(zap.NewNop()) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + body, err := io.ReadAll(rec.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(body) != "OK" { + t.Errorf("body = %q, want %q", body, "OK") + } + + if ct := rec.Header().Get("Content-Type"); ct != "text/plain; charset=UTF-8" { + t.Errorf("Content-Type = %q, want text/plain; charset=UTF-8", ct) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..79ddc0e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "os" + "testing" +) + +var configEnvKeys = []string{ + "API_PORT", + "API_READ_TIMEOUT", + "API_WRITE_TIMEOUT", + "API_IDLE_TIMEOUT", + "API_TIMEOUT", + "API_API_PORT", + "API_API_READ_TIMEOUT", + "API_API_WRITE_TIMEOUT", + "API_API_IDLE_TIMEOUT", + "API_API_TIMEOUT", +} + +func clearConfigEnv(t *testing.T) { + t.Helper() + for _, key := range configEnvKeys { + key := key + if prev, ok := os.LookupEnv(key); ok { + prev := prev + t.Cleanup(func() { + _ = os.Setenv(key, prev) + }) + } else { + t.Cleanup(func() { + _ = os.Unsetenv(key) + }) + } + _ = os.Unsetenv(key) + } +} + +func TestNewConfig_Defaults(t *testing.T) { + clearConfigEnv(t) + + cfg, err := NewConfig() + if err != nil { + t.Fatalf("NewConfig() error = %v", err) + } + + if cfg.API.Port != 3000 { + t.Errorf("API.Port = %d, want 3000", cfg.API.Port) + } + if cfg.API.ReadTimeout != 7 { + t.Errorf("API.ReadTimeout = %d, want 7", cfg.API.ReadTimeout) + } + if cfg.API.WriteTimeout != 5 { + t.Errorf("API.WriteTimeout = %d, want 5", cfg.API.WriteTimeout) + } + if cfg.API.IdleTimeout != 5 { + t.Errorf("API.IdleTimeout = %d, want 5", cfg.API.IdleTimeout) + } + if cfg.API.Timeout != 5 { + t.Errorf("API.Timeout = %d, want 5", cfg.API.Timeout) + } +} + +func TestNewConfig_FromEnv(t *testing.T) { + t.Setenv("API_PORT", "9090") + t.Setenv("API_READ_TIMEOUT", "10") + t.Setenv("API_WRITE_TIMEOUT", "11") + t.Setenv("API_IDLE_TIMEOUT", "12") + t.Setenv("API_TIMEOUT", "13") + + cfg, err := NewConfig() + if err != nil { + t.Fatalf("NewConfig() error = %v", err) + } + + if cfg.API.Port != 9090 { + t.Errorf("API.Port = %d, want 9090", cfg.API.Port) + } + if cfg.API.ReadTimeout != 10 { + t.Errorf("API.ReadTimeout = %d, want 10", cfg.API.ReadTimeout) + } + if cfg.API.WriteTimeout != 11 { + t.Errorf("API.WriteTimeout = %d, want 11", cfg.API.WriteTimeout) + } + if cfg.API.IdleTimeout != 12 { + t.Errorf("API.IdleTimeout = %d, want 12", cfg.API.IdleTimeout) + } + if cfg.API.Timeout != 13 { + t.Errorf("API.Timeout = %d, want 13", cfg.API.Timeout) + } +} diff --git a/internal/infrastructure/os/signal_test.go b/internal/infrastructure/os/signal_test.go new file mode 100644 index 0000000..48aad97 --- /dev/null +++ b/internal/infrastructure/os/signal_test.go @@ -0,0 +1,38 @@ +package os + +import ( + "context" + "os" + "syscall" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestSignalListener_CancelsOnSIGTERM(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + canceled := make(chan struct{}) + go func() { + <-ctx.Done() + close(canceled) + }() + + SignalListener(zap.NewNop(), cancel) + + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess: %v", err) + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("Signal SIGTERM: %v", err) + } + + select { + case <-canceled: + case <-time.After(2 * time.Second): + t.Fatal("context was not canceled after SIGTERM") + } +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go new file mode 100644 index 0000000..0595f74 --- /dev/null +++ b/pkg/logger/logger_test.go @@ -0,0 +1,27 @@ +package logger + +import ( + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestInitializeZapGlobals_InfoLevel(t *testing.T) { + initializeZapGlobals("info") + + if zap.L().Core().Enabled(zapcore.DebugLevel) { + t.Error("debug level should be disabled when LOG_LEVEL is info") + } + if !zap.L().Core().Enabled(zapcore.InfoLevel) { + t.Error("info level should be enabled") + } +} + +func TestInitializeZapGlobals_DebugLevel(t *testing.T) { + initializeZapGlobals("debug") + + if !zap.L().Core().Enabled(zapcore.DebugLevel) { + t.Error("debug level should be enabled") + } +}