* HH-442: isolate runtime processes and harden shutdown * HH-442: harden worker shutdown races * HH-442: gate dependency shutdown on active handlers --------- Co-authored-by: Rogee <rogee@ipao.vip>
230 lines
6.8 KiB
Go
230 lines
6.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
return db
|
|
}
|
|
|
|
func TestHealthHandler_Healthy(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newTestDB(t)
|
|
defer func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
}()
|
|
|
|
router := gin.New()
|
|
router.GET("/health", HealthHandler(db, time.Now().Add(-5*time.Minute), "1.0.0"))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp HealthResponse
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "healthy", resp.Status)
|
|
assert.Equal(t, "1.0.0", resp.Version)
|
|
assert.NotEmpty(t, resp.Timestamp)
|
|
assert.NotEmpty(t, resp.Uptime)
|
|
assert.NotEmpty(t, resp.Checks)
|
|
assert.Equal(t, "healthy", resp.Checks["database"])
|
|
}
|
|
|
|
func TestHealthHandler_NilDB_Unhealthy(t *testing.T) {
|
|
// HealthHandler does not handle nil DB (panics on db.DB()).
|
|
// This is acceptable — the handler is always called with a real DB in production.
|
|
t.Skip("HealthHandler panics on nil DB; nil DB is not a valid runtime state")
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.GET("/health", HealthHandler(nil, time.Now(), "1.0.0"))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
|
|
var resp HealthResponse
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "unhealthy", resp.Status)
|
|
assert.Contains(t, resp.Checks["database"], "unhealthy")
|
|
}
|
|
|
|
func TestReadyHandler_Ready(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newTestDB(t)
|
|
defer func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
}()
|
|
|
|
router := gin.New()
|
|
router.GET("/ready", ReadyHandler(db))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/ready", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.True(t, resp["ready"].(bool))
|
|
}
|
|
|
|
func TestReadyHandler_RequiredDependencyFailure(t *testing.T) {
|
|
db := newTestDB(t)
|
|
router := gin.New()
|
|
router.GET("/ready", ReadyHandler(db, DependencyCheck{Name: "redis", Check: func(context.Context) error {
|
|
return errors.New("connection refused")
|
|
}}))
|
|
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ready", nil))
|
|
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
assert.JSONEq(t, `{"ready":false,"checks":{"database":"healthy","redis":"unhealthy: connection refused"}}`, w.Body.String())
|
|
}
|
|
|
|
func TestDatabaseOutageFailsReadinessButNotLiveness(t *testing.T) {
|
|
db := newTestDB(t)
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
router := gin.New()
|
|
router.GET("/ready", ReadyHandler(db))
|
|
router.GET("/live", LiveHandler())
|
|
|
|
ready := httptest.NewRecorder()
|
|
router.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil))
|
|
assert.Equal(t, http.StatusServiceUnavailable, ready.Code)
|
|
live := httptest.NewRecorder()
|
|
router.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/live", nil))
|
|
assert.Equal(t, http.StatusOK, live.Code)
|
|
}
|
|
|
|
func TestHealthHandler_SelectedDependency(t *testing.T) {
|
|
db := newTestDB(t)
|
|
router := gin.New()
|
|
router.GET("/health", HealthHandler(db, time.Now(), "test", DependencyCheck{Name: "redis", Check: func(context.Context) error { return nil }}))
|
|
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health?check=redis", nil))
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var response HealthResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
assert.Equal(t, map[string]string{"redis": "healthy"}, response.Checks)
|
|
}
|
|
|
|
func TestReadyHandler_NilDB_NotReady(t *testing.T) {
|
|
// ReadyHandler does not handle nil DB (panics on db.DB()).
|
|
t.Skip("ReadyHandler panics on nil DB; nil DB is not a valid runtime state")
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.GET("/ready", ReadyHandler(nil))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/ready", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
}
|
|
|
|
func TestLiveHandler(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.GET("/live", LiveHandler())
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/live", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.True(t, resp["alive"].(bool))
|
|
}
|
|
|
|
func TestPrometheusHandler(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.GET("/metrics", PrometheusHandler(time.Now().Add(-1*time.Minute)))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/metrics", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Header().Get("Content-Type"), "text/plain")
|
|
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, "gochat_go_goroutines")
|
|
assert.Contains(t, body, "gochat_go_memory_alloc_bytes")
|
|
assert.Contains(t, body, "gochat_uptime_seconds")
|
|
assert.Contains(t, body, "# HELP")
|
|
assert.Contains(t, body, "# TYPE")
|
|
}
|
|
|
|
func TestHTTPMetricsRecordsRequestsErrorsAndLatency(t *testing.T) {
|
|
metrics := NewHTTPMetrics(time.Now())
|
|
router := gin.New()
|
|
router.Use(metrics.Middleware())
|
|
router.GET("/items/:id", func(c *gin.Context) { c.Status(http.StatusInternalServerError) })
|
|
router.GET("/metrics", metrics.Handler())
|
|
|
|
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/items/42", nil))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
|
|
body := w.Body.String()
|
|
assert.Contains(t, body, `http_requests_total{method="GET",route="/items/:id",status="500"} 1`)
|
|
assert.Contains(t, body, `http_request_errors_total{method="GET",route="/items/:id",status="500"} 1`)
|
|
assert.Contains(t, body, `http_request_duration_seconds_count{method="GET",route="/items/:id",status="500"} 1`)
|
|
}
|
|
|
|
func TestFormatGauge(t *testing.T) {
|
|
result := formatGauge("test_metric", 42)
|
|
assert.Equal(t, "test_metric 42", result)
|
|
}
|
|
|
|
func TestFormatCounter(t *testing.T) {
|
|
result := formatCounter("test_counter", 100)
|
|
assert.Equal(t, "test_counter 100", result)
|
|
}
|
|
|
|
func TestFormatValue_Zero(t *testing.T) {
|
|
assert.Equal(t, "0", formatValue(0))
|
|
}
|
|
|
|
func TestFormatValue_Positive(t *testing.T) {
|
|
assert.Equal(t, "12345", formatValue(12345))
|
|
}
|
|
|
|
func TestFormatValue_Large(t *testing.T) {
|
|
assert.Equal(t, "999999999", formatValue(999999999))
|
|
}
|