package metrics import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" ) func fakeModuleServer(metricName string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") fmt.Fprintf(w, "# HELP %s ein test-zaehler\n# TYPE %s counter\n%s 42\n", metricName, metricName, metricName) })) } // Akzeptanzkriterium 1: Core liefert unter dem Handler valides // Prometheus-Textformat mit den eigenen Kennzahlen. func TestHandler_ServesCoreMetricsInPrometheusFormat(t *testing.T) { agg := NewAggregator(NewCoreRegistry(), func(ctx context.Context) ([]Source, error) { return nil, nil }) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) rec := httptest.NewRecorder() agg.Handler()(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } parser := expfmt.NewTextParser(model.LegacyValidation) families, err := parser.TextToMetricFamilies(strings.NewReader(rec.Body.String())) if err != nil { t.Fatalf("antwort ist kein valides prometheus-textformat: %v", err) } if _, ok := families["nexarch_core_up"]; !ok { t.Fatalf("erwartet 'nexarch_core_up' unter den core-metriken, habe: %v", keysOf(families)) } } func keysOf[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out } // Akzeptanzkriterium 2 + Pruefung 2: ein NEU registriertes Modul erscheint // in der Aggregation, OHNE dass dieses Paket oder der Aufrufer Code // aendern muss — die Quelle kommt ausschliesslich aus sourceProvider. func TestHandler_NewlyRegisteredModuleAppearsWithoutCodeChange(t *testing.T) { moduleServer := fakeModuleServer("requests_total") defer moduleServer.Close() // Simuliert eine sich zur Laufzeit aendernde Modul-Liste (z.B. aus // SourceStore.Provide) — zunaechst LEER, dann mit einem Eintrag. var sources []Source var mu sync.Mutex provider := func(ctx context.Context) ([]Source, error) { mu.Lock() defer mu.Unlock() out := make([]Source, len(sources)) copy(out, sources) return out, nil } agg := NewAggregator(NewCoreRegistry(), provider) // Vor der Registrierung: Modul-Metrik nicht vorhanden. rec1 := httptest.NewRecorder() agg.Handler()(rec1, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if strings.Contains(rec1.Body.String(), "requests_total") { t.Fatal("modul-metrik haette vor registrierung nicht erscheinen duerfen") } // Modul wird "registriert" (kein Code hier oder in metrics.go aendert sich). mu.Lock() sources = append(sources, Source{ModuleName: "dms", MetricsURL: moduleServer.URL}) mu.Unlock() rec2 := httptest.NewRecorder() agg.Handler()(rec2, httptest.NewRequest(http.MethodGet, "/metrics", nil)) body := rec2.Body.String() if !strings.Contains(body, "nexarch_module_dms_requests_total") { t.Fatalf("erwartet umbenannte modul-metrik 'nexarch_module_dms_requests_total' nach registrierung, body:\n%s", body) } } // Akzeptanzkriterium 3: Namenskonvention "nexarch_module__" // wird tatsaechlich angewendet. func TestFetchAndRename_AppliesNamingConvention(t *testing.T) { moduleServer := fakeModuleServer("queue_depth") defer moduleServer.Close() agg := NewAggregator(NewCoreRegistry(), nil) families, err := agg.fetchAndRename(context.Background(), Source{ModuleName: "mail", MetricsURL: moduleServer.URL}) if err != nil { t.Fatalf("fetchAndRename: %v", err) } if len(families) != 1 || families[0].GetName() != "nexarch_module_mail_queue_depth" { t.Fatalf("erwartet genau 1 metrik 'nexarch_module_mail_queue_depth', habe: %+v", families) } } // Ein nicht erreichbares Modul darf die Aggregation der uebrigen und die // Gesamtantwort nicht verhindern (dieselbe Resilienz wie OPS-02). func TestHandler_UnreachableModuleDoesNotBreakAggregation(t *testing.T) { reachable := fakeModuleServer("healthy_metric") defer reachable.Close() unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) unreachableURL := unreachable.URL unreachable.Close() // sofort schliessen -> Verbindung schlaegt fehl provider := func(ctx context.Context) ([]Source, error) { return []Source{ {ModuleName: "ok", MetricsURL: reachable.URL}, {ModuleName: "kaputt", MetricsURL: unreachableURL}, }, nil } agg := NewAggregator(NewCoreRegistry(), provider) rec := httptest.NewRecorder() agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 trotz einem nicht erreichbaren modul", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "nexarch_module_ok_healthy_metric") { t.Fatal("erreichbares modul haette trotz ausfall des anderen aggregiert werden sollen") } if strings.Contains(body, "kaputt") { t.Fatal("nicht erreichbares modul haette keine metrik beitragen duerfen") } } // Pruefung 1: Endpunkt antwortet unter mehreren gleichzeitigen Anfragen // innerhalb definierter Zeit — kein unbeschraenktes Blockieren durch // langsame Module (FetchTimeout begrenzt jeden Scrape). func TestHandler_RespondsWithinBoundedTimeUnderLoad(t *testing.T) { hangingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(10 * time.Second) // wuerde ohne timeout jede anfrage blockieren })) defer hangingServer.Close() provider := func(ctx context.Context) ([]Source, error) { return []Source{{ModuleName: "haengend", MetricsURL: hangingServer.URL}}, nil } agg := NewAggregator(NewCoreRegistry(), provider) const concurrentRequests = 10 var wg sync.WaitGroup start := time.Now() for i := 0; i < concurrentRequests; i++ { wg.Add(1) go func() { defer wg.Done() rec := httptest.NewRecorder() agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200", rec.Code) } }() } wg.Wait() elapsed := time.Since(start) if elapsed > FetchTimeout+3*time.Second { t.Fatalf("%d gleichzeitige anfragen brauchten %s, erwartet deutlich unter %s durch FetchTimeout", concurrentRequests, elapsed, FetchTimeout+3*time.Second) } }