orama/pkg/deployments/health/checker_test.go
2026-02-13 16:18:22 +02:00

452 lines
13 KiB
Go

package health
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
// ---------------------------------------------------------------------------
// Mock database
// ---------------------------------------------------------------------------
// queryCall records the arguments passed to a Query invocation.
type queryCall struct {
query string
args []interface{}
}
// execCall records the arguments passed to an Exec invocation.
type execCall struct {
query string
args []interface{}
}
// mockDB implements database.Database with configurable responses.
type mockDB struct {
mu sync.Mutex
// Query handling ---------------------------------------------------
// queryFunc is called when Query is invoked. It receives the dest
// pointer and the query string + args. The implementation should
// populate dest (via reflection) and return an error if desired.
queryFunc func(dest interface{}, query string, args ...interface{}) error
queryCalls []queryCall
// Exec handling ----------------------------------------------------
execFunc func(query string, args ...interface{}) (interface{}, error)
execCalls []execCall
}
func (m *mockDB) Query(_ context.Context, dest interface{}, query string, args ...interface{}) error {
m.mu.Lock()
m.queryCalls = append(m.queryCalls, queryCall{query: query, args: args})
fn := m.queryFunc
m.mu.Unlock()
if fn != nil {
return fn(dest, query, args...)
}
return nil
}
func (m *mockDB) QueryOne(_ context.Context, dest interface{}, query string, args ...interface{}) error {
m.mu.Lock()
m.queryCalls = append(m.queryCalls, queryCall{query: query, args: args})
m.mu.Unlock()
return nil
}
func (m *mockDB) Exec(_ context.Context, query string, args ...interface{}) (interface{}, error) {
m.mu.Lock()
m.execCalls = append(m.execCalls, execCall{query: query, args: args})
fn := m.execFunc
m.mu.Unlock()
if fn != nil {
return fn(query, args...)
}
return nil, nil
}
// getExecCalls returns a snapshot of the recorded Exec calls.
func (m *mockDB) getExecCalls() []execCall {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]execCall, len(m.execCalls))
copy(out, m.execCalls)
return out
}
// getQueryCalls returns a snapshot of the recorded Query calls.
func (m *mockDB) getQueryCalls() []queryCall {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]queryCall, len(m.queryCalls))
copy(out, m.queryCalls)
return out
}
// ---------------------------------------------------------------------------
// Helper: populate a *[]T dest via reflection so the mock can return rows.
// ---------------------------------------------------------------------------
// appendRows appends rows to dest (a *[]SomeStruct) by creating new elements
// of the destination's element type and copying field values by name.
// Each row is a map[string]interface{} keyed by field name (Go name, not db tag).
// This sidesteps the type-identity problem where the mock and the caller
// define structurally identical but distinct local types.
func appendRows(dest interface{}, rows []map[string]interface{}) {
dv := reflect.ValueOf(dest).Elem() // []T
elemType := dv.Type().Elem() // T
for _, row := range rows {
elem := reflect.New(elemType).Elem()
for name, val := range row {
f := elem.FieldByName(name)
if f.IsValid() && f.CanSet() {
f.Set(reflect.ValueOf(val))
}
}
dv = reflect.Append(dv, elem)
}
reflect.ValueOf(dest).Elem().Set(dv)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// ---- a) NewHealthChecker --------------------------------------------------
func TestNewHealthChecker_NonNil(t *testing.T) {
db := &mockDB{}
logger := zap.NewNop()
hc := NewHealthChecker(db, logger)
if hc == nil {
t.Fatal("expected non-nil HealthChecker")
}
if hc.db != db {
t.Error("expected db to be stored")
}
if hc.logger != logger {
t.Error("expected logger to be stored")
}
if hc.workers != 10 {
t.Errorf("expected default workers=10, got %d", hc.workers)
}
if hc.active == nil {
t.Error("expected active map to be initialized")
}
if len(hc.active) != 0 {
t.Errorf("expected active map to be empty, got %d entries", len(hc.active))
}
}
// ---- b) checkDeployment ---------------------------------------------------
func TestCheckDeployment_StaticDeployment(t *testing.T) {
db := &mockDB{}
hc := NewHealthChecker(db, zap.NewNop())
dep := deploymentRow{
ID: "dep-1",
Name: "static-site",
Port: 0, // static deployment
}
if !hc.checkDeployment(context.Background(), dep) {
t.Error("static deployment (port 0) should always be healthy")
}
}
func TestCheckDeployment_HealthyEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// Extract the port from the test server.
port := serverPort(t, srv)
db := &mockDB{}
hc := NewHealthChecker(db, zap.NewNop())
dep := deploymentRow{
ID: "dep-2",
Name: "web-app",
Port: port,
HealthCheckPath: "/healthz",
}
if !hc.checkDeployment(context.Background(), dep) {
t.Error("expected healthy for 200 response")
}
}
func TestCheckDeployment_UnhealthyEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
port := serverPort(t, srv)
db := &mockDB{}
hc := NewHealthChecker(db, zap.NewNop())
dep := deploymentRow{
ID: "dep-3",
Name: "broken-app",
Port: port,
HealthCheckPath: "/healthz",
}
if hc.checkDeployment(context.Background(), dep) {
t.Error("expected unhealthy for 500 response")
}
}
func TestCheckDeployment_UnreachableEndpoint(t *testing.T) {
db := &mockDB{}
hc := NewHealthChecker(db, zap.NewNop())
dep := deploymentRow{
ID: "dep-4",
Name: "ghost-app",
Port: 19999, // nothing listening here
HealthCheckPath: "/healthz",
}
if hc.checkDeployment(context.Background(), dep) {
t.Error("expected unhealthy for unreachable endpoint")
}
}
// ---- c) checkConsecutiveFailures ------------------------------------------
func TestCheckConsecutiveFailures_HealthyReturnsEarly(t *testing.T) {
db := &mockDB{}
hc := NewHealthChecker(db, zap.NewNop())
// When the current check is healthy, the method returns immediately
// without querying the database.
hc.checkConsecutiveFailures(context.Background(), "dep-1", true)
calls := db.getQueryCalls()
if len(calls) != 0 {
t.Errorf("expected no query calls when healthy, got %d", len(calls))
}
}
func TestCheckConsecutiveFailures_LessThan3Failures(t *testing.T) {
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
// Return only 2 unhealthy rows (fewer than 3).
appendRows(dest, []map[string]interface{}{
{"Status": "unhealthy"},
{"Status": "unhealthy"},
})
return nil
},
}
hc := NewHealthChecker(db, zap.NewNop())
hc.checkConsecutiveFailures(context.Background(), "dep-1", false)
// Should query the DB but NOT issue any UPDATE or event INSERT.
execCalls := db.getExecCalls()
if len(execCalls) != 0 {
t.Errorf("expected 0 exec calls with <3 failures, got %d", len(execCalls))
}
}
func TestCheckConsecutiveFailures_ThreeConsecutive(t *testing.T) {
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
appendRows(dest, []map[string]interface{}{
{"Status": "unhealthy"},
{"Status": "unhealthy"},
{"Status": "unhealthy"},
})
return nil
},
execFunc: func(query string, args ...interface{}) (interface{}, error) {
return nil, nil
},
}
hc := NewHealthChecker(db, zap.NewNop())
hc.checkConsecutiveFailures(context.Background(), "dep-99", false)
execCalls := db.getExecCalls()
// Expect 2 exec calls: one UPDATE (mark failed) + one INSERT (event).
if len(execCalls) != 2 {
t.Fatalf("expected 2 exec calls (update + event), got %d", len(execCalls))
}
// First call: UPDATE deployments SET status = 'failed'
if !strings.Contains(execCalls[0].query, "UPDATE deployments") {
t.Errorf("expected UPDATE deployments query, got: %s", execCalls[0].query)
}
if !strings.Contains(execCalls[0].query, "status = 'failed'") {
t.Errorf("expected status='failed' in query, got: %s", execCalls[0].query)
}
// Second call: INSERT INTO deployment_events
if !strings.Contains(execCalls[1].query, "INSERT INTO deployment_events") {
t.Errorf("expected INSERT INTO deployment_events, got: %s", execCalls[1].query)
}
if !strings.Contains(execCalls[1].query, "health_failed") {
t.Errorf("expected health_failed event_type, got: %s", execCalls[1].query)
}
// Verify the deployment ID was passed to both queries.
for i, call := range execCalls {
found := false
for _, arg := range call.args {
if arg == "dep-99" {
found = true
break
}
}
if !found {
t.Errorf("exec call %d: expected deployment id 'dep-99' in args %v", i, call.args)
}
}
}
func TestCheckConsecutiveFailures_MixedResults(t *testing.T) {
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
// 3 rows but NOT all unhealthy — no action should be taken.
appendRows(dest, []map[string]interface{}{
{"Status": "unhealthy"},
{"Status": "healthy"},
{"Status": "unhealthy"},
})
return nil
},
}
hc := NewHealthChecker(db, zap.NewNop())
hc.checkConsecutiveFailures(context.Background(), "dep-mixed", false)
execCalls := db.getExecCalls()
if len(execCalls) != 0 {
t.Errorf("expected 0 exec calls with mixed results, got %d", len(execCalls))
}
}
// ---- d) GetHealthStatus ---------------------------------------------------
func TestGetHealthStatus_ReturnsChecks(t *testing.T) {
now := time.Now().Truncate(time.Second)
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
appendRows(dest, []map[string]interface{}{
{"Status": "healthy", "CheckedAt": now, "ResponseTimeMs": 42},
{"Status": "unhealthy", "CheckedAt": now.Add(-30 * time.Second), "ResponseTimeMs": 5001},
})
return nil
},
}
hc := NewHealthChecker(db, zap.NewNop())
checks, err := hc.GetHealthStatus(context.Background(), "dep-1", 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(checks) != 2 {
t.Fatalf("expected 2 health checks, got %d", len(checks))
}
if checks[0].Status != "healthy" {
t.Errorf("checks[0].Status = %q, want %q", checks[0].Status, "healthy")
}
if checks[0].ResponseTimeMs != 42 {
t.Errorf("checks[0].ResponseTimeMs = %d, want 42", checks[0].ResponseTimeMs)
}
if !checks[0].CheckedAt.Equal(now) {
t.Errorf("checks[0].CheckedAt = %v, want %v", checks[0].CheckedAt, now)
}
if checks[1].Status != "unhealthy" {
t.Errorf("checks[1].Status = %q, want %q", checks[1].Status, "unhealthy")
}
if checks[1].ResponseTimeMs != 5001 {
t.Errorf("checks[1].ResponseTimeMs = %d, want 5001", checks[1].ResponseTimeMs)
}
}
func TestGetHealthStatus_EmptyList(t *testing.T) {
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
// Don't populate dest — leave the slice empty.
return nil
},
}
hc := NewHealthChecker(db, zap.NewNop())
checks, err := hc.GetHealthStatus(context.Background(), "dep-empty", 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(checks) != 0 {
t.Errorf("expected 0 health checks, got %d", len(checks))
}
}
func TestGetHealthStatus_DatabaseError(t *testing.T) {
db := &mockDB{
queryFunc: func(dest interface{}, query string, args ...interface{}) error {
return fmt.Errorf("connection refused")
},
}
hc := NewHealthChecker(db, zap.NewNop())
_, err := hc.GetHealthStatus(context.Background(), "dep-err", 10)
if err == nil {
t.Fatal("expected error from GetHealthStatus")
}
if !strings.Contains(err.Error(), "connection refused") {
t.Errorf("expected 'connection refused' in error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// serverPort extracts the port number from an httptest.Server.
func serverPort(t *testing.T, srv *httptest.Server) int {
t.Helper()
// URL is http://127.0.0.1:<port>
addr := srv.Listener.Addr().String()
var port int
// addr is "127.0.0.1:PORT"
_, err := fmt.Sscanf(addr[strings.LastIndex(addr, ":")+1:], "%d", &port)
if err != nil {
t.Fatalf("failed to parse port from %q: %v", addr, err)
}
return port
}