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)) } }