package httpapi import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" ) func TestRequestMetricsObserveAndSnapshot(t *testing.T) { t.Parallel() metrics := newRequestMetrics() metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 100*time.Millisecond) metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 200*time.Millisecond) metrics.observe(http.MethodPost, "/v1/tasks", http.StatusCreated, 40*time.Millisecond) snapshot := metrics.snapshot() if snapshot.RequestsTotal != 3 { t.Fatalf("requestsTotal = %d, want 3", snapshot.RequestsTotal) } if snapshot.StatusClassTotals["2xx"] != 3 { t.Fatalf("2xx total = %d, want 3", snapshot.StatusClassTotals["2xx"]) } if len(snapshot.Routes) != 2 { t.Fatalf("route bucket count = %d, want 2", len(snapshot.Routes)) } if snapshot.UptimeSeconds < 0 { t.Fatalf("uptimeSeconds = %d, want >= 0", snapshot.UptimeSeconds) } health := findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/health", http.StatusOK) if health == nil { t.Fatal("missing route metric for GET /v1/health 200") } if health.Count != 2 { t.Fatalf("health count = %d, want 2", health.Count) } if health.AvgLatencyMs != 150 { t.Fatalf("health avgLatencyMs = %.2f, want 150", health.AvgLatencyMs) } if health.MaxLatencyMs != 200 { t.Fatalf("health maxLatencyMs = %.2f, want 200", health.MaxLatencyMs) } if _, err := time.Parse(time.RFC3339Nano, health.LastSeenAt); err != nil { t.Fatalf("health lastSeenAt parse error: %v", err) } if _, err := time.Parse(time.RFC3339Nano, snapshot.GeneratedAt); err != nil { t.Fatalf("snapshot generatedAt parse error: %v", err) } } func TestRequestMetricsMiddlewareSkipsMetricsEndpoint(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) metrics := newRequestMetrics() router := gin.New() router.Use(requestMetricsMiddleware(metrics)) router.GET("/v1/health", func(c *gin.Context) { c.Status(http.StatusOK) }) router.GET("/v1/metrics", func(c *gin.Context) { c.Status(http.StatusOK) }) router.GET("/v1/metrics/prometheus", func(c *gin.Context) { c.Status(http.StatusOK) }) healthRequest := httptest.NewRequest(http.MethodGet, "/v1/health", nil) healthResponse := httptest.NewRecorder() router.ServeHTTP(healthResponse, healthRequest) if healthResponse.Code != http.StatusOK { t.Fatalf("GET /v1/health status = %d, want 200", healthResponse.Code) } metricsRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil) metricsResponse := httptest.NewRecorder() router.ServeHTTP(metricsResponse, metricsRequest) if metricsResponse.Code != http.StatusOK { t.Fatalf("GET /v1/metrics status = %d, want 200", metricsResponse.Code) } prometheusRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics/prometheus", nil) prometheusResponse := httptest.NewRecorder() router.ServeHTTP(prometheusResponse, prometheusRequest) if prometheusResponse.Code != http.StatusOK { t.Fatalf("GET /v1/metrics/prometheus status = %d, want 200", prometheusResponse.Code) } snapshot := metrics.snapshot() if snapshot.RequestsTotal != 1 { t.Fatalf("requestsTotal = %d, want 1", snapshot.RequestsTotal) } if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics", http.StatusOK) != nil { t.Fatal("metrics endpoint request should be excluded from tracking") } if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics/prometheus", http.StatusOK) != nil { t.Fatal("prometheus metrics endpoint request should be excluded from tracking") } } func TestSnapshotPrometheus(t *testing.T) { t.Parallel() metrics := newRequestMetrics() metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 50*time.Millisecond) metrics.observe(http.MethodGet, "/v1/tasks", http.StatusNotFound, 25*time.Millisecond) metrics.observe(http.MethodGet, `/v1/quoted"path`, http.StatusOK, 35*time.Millisecond) output := metrics.snapshotPrometheus() expectedFragments := []string{ "productier_http_uptime_seconds", `productier_http_requests_total{status_class="2xx"} 2`, `productier_http_requests_total{status_class="4xx"} 1`, `productier_http_requests_route_total{method="GET",path="/v1/health",status="200"} 1`, `productier_http_request_latency_avg_ms{method="GET",path="/v1/health",status="200"} 50.000`, `productier_http_request_latency_max_ms{method="GET",path="/v1/tasks",status="404"} 25.000`, `path="/v1/quoted\"path"`, } for _, fragment := range expectedFragments { if !strings.Contains(output, fragment) { t.Fatalf("expected prometheus output to contain %q\noutput:\n%s", fragment, output) } } } func TestItoa(t *testing.T) { t.Parallel() cases := map[int]string{ 0: "0", 7: "7", 42: "42", -10: "-10", 2048: "2048", } for input, want := range cases { if got := itoa(input); got != want { t.Fatalf("itoa(%d) = %q, want %q", input, got, want) } } } func findRouteMetric(routes []routeMetricSnapshot, method, path string, status int) *routeMetricSnapshot { for _, route := range routes { if route.Method == method && route.Path == path && route.Status == status { result := route return &result } } return nil }