package receiver import ( "encoding/json" "net/http" "net/http/httptest" "testing" "edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/summary" ) func TestFormatMemoryK8s(t *testing.T) { tests := []struct { bytes float64 want string }{ {0, "0Mi"}, {1024 * 1024, "1Mi"}, {256 * 1024 * 1024, "256Mi"}, {512 * 1024 * 1024, "512Mi"}, {1024 * 1024 * 1024, "1024Mi"}, {2 * 1024 * 1024 * 1024, "2048Mi"}, {1.5 * 1024 * 1024 * 1024, "1536Mi"}, {100 * 1024 * 1024, "100Mi"}, } for _, tt := range tests { got := formatMemoryK8s(tt.bytes) if got != tt.want { t.Errorf("formatMemoryK8s(%v) = %q, want %q", tt.bytes, got, tt.want) } } } func TestFormatCPUK8s(t *testing.T) { tests := []struct { cores float64 want string }{ {0, "0m"}, {0.1, "100m"}, {0.5, "500m"}, {1.0, "1"}, {1.5, "1500m"}, {2.0, "2"}, {2.5, "2500m"}, {0.123, "123m"}, } for _, tt := range tests { got := formatCPUK8s(tt.cores) if got != tt.want { t.Errorf("formatCPUK8s(%v) = %q, want %q", tt.cores, got, tt.want) } } } func TestRoundUpMemoryLimit(t *testing.T) { Mi := float64(1024 * 1024) tests := []struct { bytes float64 want float64 }{ {0, Mi}, // minimum 1Mi {100, Mi}, // rounds up to 1Mi {Mi, Mi}, // exactly 1Mi stays 1Mi {1.5 * Mi, 2 * Mi}, {200 * Mi, 256 * Mi}, {300 * Mi, 512 * Mi}, {600 * Mi, 1024 * Mi}, } for _, tt := range tests { got := roundUpMemoryLimit(tt.bytes) if got != tt.want { t.Errorf("roundUpMemoryLimit(%v) = %v, want %v", tt.bytes, got, tt.want) } } } func TestRoundUpCPULimit(t *testing.T) { tests := []struct { cores float64 want float64 }{ {0, 0.5}, // minimum 0.5 {0.1, 0.5}, {0.5, 0.5}, {0.6, 1.0}, {1.0, 1.0}, {1.1, 1.5}, {1.5, 1.5}, {2.0, 2.0}, {2.3, 2.5}, } for _, tt := range tests { got := roundUpCPULimit(tt.cores) if got != tt.want { t.Errorf("roundUpCPULimit(%v) = %v, want %v", tt.cores, got, tt.want) } } } func TestSelectCPUValue(t *testing.T) { stats := summary.StatSummary{ Peak: 10.0, P99: 9.0, P95: 8.0, P75: 6.0, P50: 5.0, Avg: 4.0, } tests := []struct { percentile string want float64 }{ {"peak", 10.0}, {"p99", 9.0}, {"p95", 8.0}, {"p75", 6.0}, {"p50", 5.0}, {"avg", 4.0}, {"invalid", 8.0}, // defaults to p95 } for _, tt := range tests { got := selectCPUValue(stats, tt.percentile) if got != tt.want { t.Errorf("selectCPUValue(stats, %q) = %v, want %v", tt.percentile, got, tt.want) } } } func TestIsValidPercentile(t *testing.T) { valid := []string{"peak", "p99", "p95", "p75", "p50", "avg"} for _, p := range valid { if !IsValidPercentile(p) { t.Errorf("IsValidPercentile(%q) = false, want true", p) } } invalid := []string{"p80", "p90", "max", ""} for _, p := range invalid { if IsValidPercentile(p) { t.Errorf("IsValidPercentile(%q) = true, want false", p) } } } func TestComputeSizing_SingleRun(t *testing.T) { runSummary := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4}, MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, // 512Mi }, }, } payload, _ := json.Marshal(runSummary) metrics := []Metric{{Payload: string(payload)}} resp, err := computeSizing(metrics, 20, "p95") if err != nil { t.Fatalf("computeSizing() error = %v", err) } if len(resp.Containers) != 1 { t.Fatalf("got %d containers, want 1", len(resp.Containers)) } c := resp.Containers[0] if c.Name != "runner" { t.Errorf("container name = %q, want %q", c.Name, "runner") } // CPU: 0.8 * 1.2 = 0.96 -> 960m request, 1 limit if c.CPU.Request != "960m" { t.Errorf("CPU request = %q, want %q", c.CPU.Request, "960m") } if c.CPU.Limit != "1" { t.Errorf("CPU limit = %q, want %q", c.CPU.Limit, "1") } // Memory: 512Mi * 1.2 = 614.4Mi -> 615Mi request, 1024Mi limit if c.Memory.Request != "615Mi" { t.Errorf("Memory request = %q, want %q", c.Memory.Request, "615Mi") } if c.Memory.Limit != "1024Mi" { t.Errorf("Memory limit = %q, want %q", c.Memory.Limit, "1024Mi") } if resp.Meta.RunsAnalyzed != 1 { t.Errorf("runs_analyzed = %d, want 1", resp.Meta.RunsAnalyzed) } if resp.Meta.BufferPercent != 20 { t.Errorf("buffer_percent = %d, want 20", resp.Meta.BufferPercent) } if resp.Meta.CPUPercentile != "p95" { t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p95") } } func TestComputeSizing_MultipleRuns(t *testing.T) { // Run 1: lower values run1 := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{Peak: 0.5, P95: 0.4}, MemoryBytes: summary.StatSummary{Peak: 256 * 1024 * 1024}, }, }, } // Run 2: higher values (should be used) run2 := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{Peak: 1.0, P95: 0.8}, MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, }, }, } payload1, _ := json.Marshal(run1) payload2, _ := json.Marshal(run2) metrics := []Metric{ {Payload: string(payload1)}, {Payload: string(payload2)}, } resp, err := computeSizing(metrics, 0, "p95") // no buffer for easier math if err != nil { t.Fatalf("computeSizing() error = %v", err) } c := resp.Containers[0] // CPU: max(0.4, 0.8) = 0.8 if c.CPU.Request != "800m" { t.Errorf("CPU request = %q, want %q", c.CPU.Request, "800m") } // Memory: max(256, 512) = 512Mi if c.Memory.Request != "512Mi" { t.Errorf("Memory request = %q, want %q", c.Memory.Request, "512Mi") } if resp.Meta.RunsAnalyzed != 2 { t.Errorf("runs_analyzed = %d, want 2", resp.Meta.RunsAnalyzed) } } func TestComputeSizing_MultipleContainers(t *testing.T) { runSummary := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{P95: 1.0}, MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, }, { Name: "dind", CPUCores: summary.StatSummary{P95: 0.5}, MemoryBytes: summary.StatSummary{Peak: 256 * 1024 * 1024}, }, }, } payload, _ := json.Marshal(runSummary) metrics := []Metric{{Payload: string(payload)}} resp, err := computeSizing(metrics, 0, "p95") if err != nil { t.Fatalf("computeSizing() error = %v", err) } if len(resp.Containers) != 2 { t.Fatalf("got %d containers, want 2", len(resp.Containers)) } // Containers should be sorted alphabetically if resp.Containers[0].Name != "dind" { t.Errorf("first container = %q, want %q", resp.Containers[0].Name, "dind") } if resp.Containers[1].Name != "runner" { t.Errorf("second container = %q, want %q", resp.Containers[1].Name, "runner") } // Total should be sum if resp.Total.CPU.Request != "1500m" { t.Errorf("total CPU request = %q, want %q", resp.Total.CPU.Request, "1500m") } if resp.Total.Memory.Request != "768Mi" { t.Errorf("total memory request = %q, want %q", resp.Total.Memory.Request, "768Mi") } } func TestComputeSizing_NoMetrics(t *testing.T) { _, err := computeSizing([]Metric{}, 20, "p95") if err == nil { t.Error("computeSizing() with no metrics should return error") } } func TestHandler_GetSizing(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() // Save metrics with container data for i := 0; i < 3; i++ { runSummary := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4}, MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, }, }, } payload := &MetricsPayload{ Execution: ExecutionContext{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "run-" + string(rune('1'+i)), }, Summary: runSummary, } if _, err := h.store.SaveMetric(payload); err != nil { t.Fatalf("SaveMetric() error = %v", err) } } req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } var resp SizingResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(resp.Containers) != 1 { t.Errorf("got %d containers, want 1", len(resp.Containers)) } if resp.Meta.RunsAnalyzed != 3 { t.Errorf("runs_analyzed = %d, want 3", resp.Meta.RunsAnalyzed) } if resp.Meta.BufferPercent != 20 { t.Errorf("buffer_percent = %d, want 20", resp.Meta.BufferPercent) } if resp.Meta.CPUPercentile != "p95" { t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p95") } } func TestHandler_GetSizing_CustomParams(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() // Save one metric runSummary := summary.RunSummary{ Containers: []summary.ContainerSummary{ { Name: "runner", CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4}, MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, }, }, } payload := &MetricsPayload{ Execution: ExecutionContext{Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "run-1"}, Summary: runSummary, } if _, err := h.store.SaveMetric(payload); err != nil { t.Fatalf("SaveMetric() error = %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build?runs=10&buffer=10&cpu_percentile=p75", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } var resp SizingResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Meta.BufferPercent != 10 { t.Errorf("buffer_percent = %d, want 10", resp.Meta.BufferPercent) } if resp.Meta.CPUPercentile != "p75" { t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p75") } // CPU: 0.6 * 1.1 = 0.66 c := resp.Containers[0] if c.CPU.Request != "660m" { t.Errorf("CPU request = %q, want %q", c.CPU.Request, "660m") } } func TestHandler_GetSizing_NotFound(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) } } func TestHandler_GetSizing_InvalidPercentile(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build?cpu_percentile=p80", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestHandler_GetSizing_AuthRequired(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() tests := []struct { name string authHeader string wantCode int }{ {"no auth", "", http.StatusUnauthorized}, {"wrong token", "Bearer wrong-token", http.StatusUnauthorized}, {"valid token", "Bearer " + readToken, http.StatusNotFound}, // no metrics, but auth works } mux := http.NewServeMux() h.RegisterRoutes(mux) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) } }) } }