Files
gochat/backend/internal/handler/handler_test.go
T
Rogeeandrogee 798ea43c2f HH-442: isolate runtime processes and harden shutdown (#90)
* 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>
2026-08-22 02:38:15 +08:00

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