rdev/pkg/api/health_test.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

232 lines
5.2 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewHealthHandler(t *testing.T) {
tests := []struct {
name string
config HealthConfig
wantStatus int
wantHealth string
}{
{
name: "all checks healthy",
config: HealthConfig{
Service: "test-service",
Timeout: 5 * time.Second,
Checks: map[string]HealthChecker{
"db": func(ctx context.Context) error {
return nil
},
"cache": func(ctx context.Context) error {
return nil
},
},
},
wantStatus: http.StatusOK,
wantHealth: "healthy",
},
{
name: "one check unhealthy",
config: HealthConfig{
Service: "test-service",
Timeout: 5 * time.Second,
Checks: map[string]HealthChecker{
"db": func(ctx context.Context) error {
return nil
},
"cache": func(ctx context.Context) error {
return errors.New("connection refused")
},
},
},
wantStatus: http.StatusServiceUnavailable,
wantHealth: "unhealthy",
},
{
name: "no checks configured",
config: HealthConfig{
Service: "test-service",
},
wantStatus: http.StatusOK,
wantHealth: "healthy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewHealthHandler(tt.config)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("expected map in data, got %T", resp.Data)
}
if status := data["status"]; status != tt.wantHealth {
t.Errorf("health status = %q, want %q", status, tt.wantHealth)
}
if service := data["service"]; service != tt.config.Service {
t.Errorf("service = %q, want %q", service, tt.config.Service)
}
})
}
}
func TestNewHealthHandler_Timeout(t *testing.T) {
config := HealthConfig{
Service: "test-service",
Timeout: 100 * time.Millisecond,
Checks: map[string]HealthChecker{
"slow": func(ctx context.Context) error {
select {
case <-time.After(1 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
},
},
}
handler := NewHealthHandler(config)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
start := time.Now()
handler(rec, req)
duration := time.Since(start)
// Should return before the full second
if duration > 500*time.Millisecond {
t.Errorf("took %v, expected less than 500ms", duration)
}
// Should be unhealthy due to timeout
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestNewLivenessHandler(t *testing.T) {
handler := NewLivenessHandler("test-service")
req := httptest.NewRequest(http.MethodGet, "/health/live", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("expected map in data, got %T", resp.Data)
}
if status := data["status"]; status != "ok" {
t.Errorf("status = %q, want %q", status, "ok")
}
if service := data["service"]; service != "test-service" {
t.Errorf("service = %q, want %q", service, "test-service")
}
}
func TestPingChecker(t *testing.T) {
t.Run("healthy ping", func(t *testing.T) {
pingFn := func(ctx context.Context) error {
return nil
}
checker := PingChecker(pingFn)
err := checker(context.Background())
if err != nil {
t.Errorf("expected nil, got %v", err)
}
})
t.Run("unhealthy ping", func(t *testing.T) {
pingErr := errors.New("connection refused")
pingFn := func(ctx context.Context) error {
return pingErr
}
checker := PingChecker(pingFn)
err := checker(context.Background())
if err != pingErr {
t.Errorf("expected %v, got %v", pingErr, err)
}
})
}
func TestHTTPChecker(t *testing.T) {
t.Run("healthy endpoint", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
checker := HTTPChecker(server.URL)
err := checker(context.Background())
if err != nil {
t.Errorf("expected nil, got %v", err)
}
})
t.Run("unhealthy endpoint", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
checker := HTTPChecker(server.URL)
err := checker(context.Background())
if err == nil {
t.Error("expected error, got nil")
}
})
t.Run("connection refused", func(t *testing.T) {
checker := HTTPChecker("http://localhost:99999")
err := checker(context.Background())
if err == nil {
t.Error("expected error, got nil")
}
})
}