Files
ai-customer-service/test/integration/health_check_test.go
Your Name e27b2e1551 fix(integration): fix TestHealthCheck_ReadyEndpoint to use newTestApp
- newTestApp now sets cfg.Runtime.Env='test', which allows memory mode
- Ready endpoint test now goes through the full router (not direct handler)
- All integration health tests pass; full suite 23/23 PASS
- Doc updates: P0 execution board (evidence + TL-P0-1/TL-P0-2 status), QA gate (TL-P0-1/TL-P0-2 completed), production checklist (Gate B requirements)
2026-05-04 08:04:39 +08:00

290 lines
7.9 KiB
Go

package integration
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/bridge/ai-customer-service/internal/app"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/platform/health"
"github.com/bridge/ai-customer-service/internal/platform/logging"
)
// mockChecker implements health.Checker for testing.
type mockChecker struct {
name string
healthy bool
errMsg string
}
func (c *mockChecker) Name() string { return c.name }
func (c *mockChecker) Check(ctx context.Context) error {
if !c.healthy {
return &checkErr{msg: c.errMsg}
}
return nil
}
type checkErr struct{ msg string }
func (e *checkErr) Error() string { return e.msg }
// newTestApp creates a minimal app instance for health endpoint testing.
func newTestApp() *app.App {
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
cfg.HTTP.ReadTimeout = 10
cfg.HTTP.WriteTimeout = 15
cfg.HTTP.IdleTimeout = 60
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Runtime.Env = "test"
application, err := app.New(cfg, logging.New())
if err != nil {
return nil
}
return application
}
// TestHealthCheck_Returns200 verifies GET /actuator/health returns HTTP 200
// when the app starts successfully.
func TestHealthCheck_Returns200(t *testing.T) {
application := newTestApp()
if application == nil {
t.Skip("app.New() returned nil, skipping integration health test")
}
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
resp, err := http.Get(server.URL + "/actuator/health")
if err != nil {
t.Fatalf("http get error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["status"] != "UP" {
t.Fatalf("status = %v, want UP", payload["status"])
}
}
// TestHealthCheck_ContainsChecks verifies the response includes the "checks" array
// when health checkers are registered.
func TestHealthCheck_ContainsChecks(t *testing.T) {
probe := health.NewProbe()
probe.SetReady(true)
checkers := []health.Checker{
&mockChecker{name: "database", healthy: true, errMsg: ""},
&mockChecker{name: "redis", healthy: true, errMsg: ""},
}
handler := healthHandlerWithProbes(probe, checkers)
req := httptest.NewRequest(http.MethodGet, "/actuator/health", nil)
resp := httptest.NewRecorder()
handler(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error = %v", err)
}
status, ok := payload["status"].(string)
if !ok || status != "UP" {
t.Fatalf("status = %v, want UP", payload["status"])
}
checks, ok := payload["checks"].([]any)
if !ok {
t.Fatalf("checks field missing or not an array: %T", payload["checks"])
}
if len(checks) != 2 {
t.Fatalf("checks length = %d, want 2", len(checks))
}
for _, c := range checks {
check, ok := c.(map[string]any)
if !ok {
t.Fatalf("check entry not a map: %v", c)
}
if check["name"] == nil || check["name"] == "" {
t.Fatalf("check name is empty in %v", check)
}
if check["status"] != "UP" {
t.Fatalf("check status = %v, want UP", check["status"])
}
}
if payload["time"] == nil {
t.Fatalf("time field missing from health response")
}
}
// TestHealthCheck_DegradedStatus verifies DEGRADED status when a checker fails.
func TestHealthCheck_DegradedStatus(t *testing.T) {
probe := health.NewProbe()
probe.SetReady(true)
checkers := []health.Checker{
&mockChecker{name: "database", healthy: true, errMsg: ""},
&mockChecker{name: "external_api", healthy: false, errMsg: "connection refused"},
}
handler := healthHandlerWithProbes(probe, checkers)
req := httptest.NewRequest(http.MethodGet, "/actuator/health", nil)
resp := httptest.NewRecorder()
handler(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (DEGRADED still returns 200)", resp.Code)
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["status"] != "DEGRADED" {
t.Fatalf("status = %v, want DEGRADED", payload["status"])
}
checks, ok := payload["checks"].([]any)
if !ok {
t.Fatalf("checks missing from response")
}
if len(checks) != 2 {
t.Fatalf("checks length = %d, want 2", len(checks))
}
foundDown := false
for _, c := range checks {
check := c.(map[string]any)
if check["name"] == "external_api" {
foundDown = true
if check["status"] != "DOWN" {
t.Fatalf("external_api status = %v, want DOWN", check["status"])
}
if check["error"] == nil || check["error"] == "" {
t.Fatalf("external_api error missing, want 'connection refused'")
}
}
}
if !foundDown {
t.Fatalf("external_api check not found in checks list")
}
}
// TestHealthCheck_LiveEndpoint verifies GET /actuator/health/live.
func TestHealthCheck_LiveEndpoint(t *testing.T) {
application := newTestApp()
if application == nil {
t.Skip("app.New() returned nil, skipping integration health test")
}
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
resp, err := http.Get(server.URL + "/actuator/health/live")
if err != nil {
t.Fatalf("http get error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["status"] != "UP" {
t.Fatalf("liveness status = %v, want UP", payload["status"])
}
}
// TestHealthCheck_ReadyEndpoint verifies GET /actuator/health/ready.
func TestHealthCheck_ReadyEndpoint(t *testing.T) {
application := newTestApp()
if application == nil {
t.Skip("app.New() returned nil, skipping integration health test")
}
application.Probe.SetReady(true)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
resp, err := http.Get(server.URL + "/actuator/health/ready")
if err != nil {
t.Fatalf("http get error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["status"] != "UP" {
t.Fatalf("readiness status = %v, want UP", payload["status"])
}
}
// healthHandlerWithProbes creates an http.HandlerFunc that mirrors the behavior
// of health.Health for testing purposes.
func healthHandlerWithProbes(probe *health.Probe, checkers []health.Checker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, results := evaluateForTest(probe, checkers)
status := "UP"
if !ok {
status = "DEGRADED"
}
payload := map[string]any{
"status": status,
"checks": results,
"time": time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(payload)
}
}
func evaluateForTest(probe *health.Probe, checkers []health.Checker) (bool, []map[string]any) {
if probe != nil && !probe.IsLive() {
return false, []map[string]any{{"name": "liveness", "status": "DOWN", "error": "server stopping"}}
}
results := make([]map[string]any, 0, len(checkers))
healthy := true
for _, c := range checkers {
if c == nil {
continue
}
if err := c.Check(context.Background()); err != nil {
healthy = false
results = append(results, map[string]any{"name": c.Name(), "status": "DOWN", "error": err.Error()})
} else {
results = append(results, map[string]any{"name": c.Name(), "status": "UP"})
}
}
return healthy, results
}