199 lines
6.4 KiB
Go
199 lines
6.4 KiB
Go
package statuspage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*Store, func()) {
|
|
t.Helper()
|
|
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)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS status_history (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
|
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
cleanup := func() { pool.Close() }
|
|
return NewStore(pool), cleanup
|
|
}
|
|
|
|
func uniqueName(prefix string) string {
|
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
|
|
// Module.
|
|
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
|
|
store, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
defer up.Close()
|
|
|
|
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
|
|
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
|
|
t.Fatalf("target a: %v", err)
|
|
}
|
|
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
|
|
t.Fatalf("target b: %v", err)
|
|
}
|
|
|
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
|
if err := poller.PollOnce(ctx); err != nil {
|
|
t.Fatalf("poll: %v", err)
|
|
}
|
|
|
|
overview, err := store.Overview(ctx)
|
|
if err != nil {
|
|
t.Fatalf("overview: %v", err)
|
|
}
|
|
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
|
|
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
|
|
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
|
|
statusByName := map[string]Status{}
|
|
for _, m := range overview {
|
|
statusByName[m.Name] = m.Status
|
|
}
|
|
if statusByName[nameA] != StatusUp {
|
|
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
|
|
}
|
|
if statusByName[nameB] != StatusUp {
|
|
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
|
|
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
|
|
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
|
|
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
|
|
store, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
defer up.Close()
|
|
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
|
|
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
|
|
|
|
healthyName, downName := uniqueName("healthy"), uniqueName("down")
|
|
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
|
|
t.Fatalf("target healthy: %v", err)
|
|
}
|
|
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
|
|
t.Fatalf("target down: %v", err)
|
|
}
|
|
|
|
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
|
|
if err := poller.PollOnce(ctx); err != nil {
|
|
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
|
|
}
|
|
|
|
overview, err := store.Overview(ctx)
|
|
if err != nil {
|
|
t.Fatalf("overview: %v", err)
|
|
}
|
|
statusByName := map[string]Status{}
|
|
for _, m := range overview {
|
|
statusByName[m.Name] = m.Status
|
|
}
|
|
if statusByName[healthyName] != StatusUp {
|
|
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
|
|
}
|
|
if statusByName[downName] != StatusDown {
|
|
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
|
|
func TestHistory_RecordsStatusTransitions(t *testing.T) {
|
|
store, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
name := uniqueName("flaky")
|
|
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
defer up.Close()
|
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
|
t.Fatalf("target: %v", err)
|
|
}
|
|
|
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
|
if err := poller.PollOnce(ctx); err != nil {
|
|
t.Fatalf("poll 1: %v", err)
|
|
}
|
|
|
|
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
|
|
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
|
|
closedURL := up.URL
|
|
up.Close()
|
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
|
|
t.Fatalf("target aktualisieren: %v", err)
|
|
}
|
|
if err := poller.PollOnce(ctx); err != nil {
|
|
t.Fatalf("poll 2: %v", err)
|
|
}
|
|
|
|
history, err := store.History(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
|
|
}
|
|
if history[0].Status != StatusDown || history[1].Status != StatusUp {
|
|
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
|
|
}
|
|
}
|
|
|
|
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
|
|
// identischen Eintraegen fluten (siehe recordIfChanged).
|
|
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
|
|
store, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
name := uniqueName("stable")
|
|
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
defer up.Close()
|
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
|
t.Fatalf("target: %v", err)
|
|
}
|
|
|
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
|
for i := 0; i < 3; i++ {
|
|
if err := poller.PollOnce(ctx); err != nil {
|
|
t.Fatalf("poll %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
history, err := store.History(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
|
|
}
|
|
}
|