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>
232 lines
5.2 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|