internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue) — nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02) gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz- kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur Prozessstatus. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" — TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool, 503 mit "database" im Checks-Ergebnis. PASS. 2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck: ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler antwortet deutlich unter 1s. PASS. 3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure: bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
4.8 KiB
Go
162 lines
4.8 KiB
Go
package health
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
|
|
// "nicht bereit".
|
|
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
|
|
pool.Close()
|
|
|
|
reg := NewRegistry()
|
|
reg.Register("database", DatabaseChecker(pool))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
rec := httptest.NewRecorder()
|
|
reg.ReadinessHandler()(rec, req)
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
|
|
}
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
Checks map[string]interface{} `json:"checks"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("body parsen: %v", err)
|
|
}
|
|
if body.Status != "not_ready" {
|
|
t.Fatalf("status-feld = %q, want not_ready", body.Status)
|
|
}
|
|
if _, ok := body.Checks["database"]; !ok {
|
|
t.Fatal("erwartet 'database' im checks-ergebnis")
|
|
}
|
|
}
|
|
|
|
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
reg := NewRegistry()
|
|
reg.Register("database", DatabaseChecker(pool))
|
|
reg.Register("queue", QueueChecker(pool))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
rec := httptest.NewRecorder()
|
|
reg.ReadinessHandler()(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
|
|
// sich nachweislich im Fehlerfall.
|
|
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
pool.Close() // db-ausfall simulieren
|
|
|
|
reg := NewRegistry()
|
|
reg.Register("database", DatabaseChecker(pool))
|
|
|
|
livenessRec := httptest.NewRecorder()
|
|
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
|
|
if livenessRec.Code != http.StatusOK {
|
|
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
|
|
}
|
|
|
|
readinessRec := httptest.NewRecorder()
|
|
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if readinessRec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
|
|
}
|
|
|
|
if livenessRec.Code == readinessRec.Code {
|
|
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
|
|
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
|
|
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
|
|
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
|
|
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
|
|
select {
|
|
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}))
|
|
|
|
start := time.Now()
|
|
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
rec := httptest.NewRecorder()
|
|
reg.ReadinessHandler()(rec, req)
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > time.Second {
|
|
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
|
|
}
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
|
|
reg := NewRegistry().WithTimeout(time.Second)
|
|
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
|
|
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
|
|
|
|
ready, results := reg.CheckAll(context.Background())
|
|
if ready {
|
|
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
|
|
}
|
|
if !results["a"].OK {
|
|
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
|
|
}
|
|
if results["b"].OK || results["b"].Error == "" {
|
|
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
|
|
}
|
|
}
|