Skip to content

Commit f1a9ea9

Browse files
committed
test: add comprehensive integration tests for label mapping validation
Add integration tests that would have caught the label mapping issues we experienced. These tests verify: 1. **Full Integration Test**: Tests the complete collection flow with mock GHCR API responses to ensure metrics are created with correct labels without panicking. 2. **Label Consistency Test**: Tests all metrics individually to verify that the labels used in With(prometheus.Labels{}) calls match the metric definitions exactly. 3. **Error Handling Test**: Tests error scenarios to ensure they don't cause label mapping panics. These tests would have caught the 'repo' vs 'name' label mapping bug that caused the panic: label name "repo" missing in label map The tests use testutil.ToFloat64() to verify metrics are created successfully, which will panic if labels don't match metric definitions. This provides a safety net for future refactoring and ensures label mapping issues are caught before deployment.
1 parent 31a2c94 commit f1a9ea9

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
github.com/goccy/go-yaml v1.18.0 // indirect
2828
github.com/json-iterator/go v1.1.12 // indirect
2929
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
30+
github.com/kylelemons/godebug v1.1.0 // indirect
3031
github.com/leodido/go-urn v1.4.0 // indirect
3132
github.com/mattn/go-isatty v0.0.20 // indirect
3233
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package collectors
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"ghcr-exporter/internal/config"
11+
"ghcr-exporter/internal/metrics"
12+
promexporter_config "github.com/d0ugal/promexporter/config"
13+
promexporter_metrics "github.com/d0ugal/promexporter/metrics"
14+
"github.com/prometheus/client_golang/prometheus"
15+
"github.com/prometheus/client_golang/prometheus/testutil"
16+
)
17+
18+
// TestGHCRCollectorIntegration tests the full collection flow to catch label mapping issues
19+
func TestGHCRCollectorIntegration(t *testing.T) {
20+
// Create test server that returns valid GHCR API responses
21+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
switch r.URL.Path {
23+
case "/v2/d0ugal/filesystem-exporter/tags/list":
24+
w.Header().Set("Content-Type", "application/json")
25+
w.WriteHeader(http.StatusOK)
26+
w.Write([]byte(`{
27+
"name": "d0ugal/filesystem-exporter",
28+
"tags": [
29+
"v1.22.4",
30+
"v1.22.3",
31+
"latest"
32+
]
33+
}`))
34+
case "/v2/d0ugal/filesystem-exporter/manifests/latest":
35+
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
36+
w.WriteHeader(http.StatusOK)
37+
w.Write([]byte(`{
38+
"schemaVersion": 2,
39+
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
40+
"config": {
41+
"mediaType": "application/vnd.docker.container.image.v1+json",
42+
"size": 1234,
43+
"digest": "sha256:abc123"
44+
},
45+
"layers": []
46+
}`))
47+
case "/v2/d0ugal/filesystem-exporter/manifests/sha256:abc123":
48+
w.Header().Set("Content-Type", "application/vnd.docker.container.image.v1+json")
49+
w.WriteHeader(http.StatusOK)
50+
w.Write([]byte(`{
51+
"created": "2025-10-27T20:00:00Z",
52+
"config": {
53+
"Labels": {
54+
"org.opencontainers.image.created": "2025-10-27T20:00:00Z"
55+
}
56+
}
57+
}`))
58+
default:
59+
w.WriteHeader(http.StatusNotFound)
60+
}
61+
}))
62+
defer server.Close()
63+
64+
// Create test configuration
65+
cfg := &config.Config{
66+
Packages: []config.PackageGroup{
67+
{
68+
Owner: "d0ugal",
69+
Repo: "filesystem-exporter",
70+
},
71+
},
72+
GitHub: config.GitHubConfig{
73+
Token: promexporter_config.NewSensitiveString("test-token"),
74+
},
75+
}
76+
77+
// Create a fresh registry for testing
78+
prometheus.DefaultRegisterer = prometheus.NewRegistry()
79+
baseRegistry := promexporter_metrics.NewRegistry("test_exporter_info")
80+
registry := metrics.NewGHCRRegistry(baseRegistry)
81+
82+
collector := NewGHCRCollector(cfg, registry)
83+
84+
// Override the client to use our test server
85+
collector.client = server.Client()
86+
87+
// Test the collection flow
88+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
89+
defer cancel()
90+
91+
// Start the collector
92+
collector.Start(ctx)
93+
94+
// Wait a bit for collection to happen
95+
time.Sleep(100 * time.Millisecond)
96+
97+
// Cancel context to stop collection
98+
cancel()
99+
100+
// Wait for collection to complete
101+
time.Sleep(100 * time.Millisecond)
102+
103+
// Verify that metrics were created with correct labels
104+
// This is the key test - it will panic if labels don't match metric definitions
105+
106+
// Test collection metrics
107+
collectionFailedMetric := testutil.ToFloat64(registry.CollectionFailedCounter.With(prometheus.Labels{
108+
"repo": "d0ugal-filesystem-exporter",
109+
"interval": "30",
110+
}))
111+
t.Logf("Collection failed metric: %f", collectionFailedMetric)
112+
113+
collectionSuccessMetric := testutil.ToFloat64(registry.CollectionSuccessCounter.With(prometheus.Labels{
114+
"repo": "d0ugal-filesystem-exporter",
115+
"interval": "30",
116+
}))
117+
t.Logf("Collection success metric: %f", collectionSuccessMetric)
118+
119+
// Test package metrics
120+
packageVersionsMetric := testutil.ToFloat64(registry.PackageDownloadsGauge.With(prometheus.Labels{
121+
"owner": "d0ugal",
122+
"repo": "filesystem-exporter",
123+
}))
124+
t.Logf("Package versions metric: %f", packageVersionsMetric)
125+
126+
packageLastPublishedMetric := testutil.ToFloat64(registry.PackageLastPublishedGauge.With(prometheus.Labels{
127+
"owner": "d0ugal",
128+
"repo": "filesystem-exporter",
129+
}))
130+
t.Logf("Package last published metric: %f", packageLastPublishedMetric)
131+
132+
// If we get here without panicking, the label mapping is correct
133+
t.Log("✅ All metrics created successfully with correct label mapping")
134+
}
135+
136+
// TestGHCRCollectorLabelConsistency tests that all metric labels match their definitions
137+
func TestGHCRCollectorLabelConsistency(t *testing.T) {
138+
// Create a fresh registry for testing
139+
prometheus.DefaultRegisterer = prometheus.NewRegistry()
140+
baseRegistry := promexporter_metrics.NewRegistry("test_exporter_info")
141+
registry := metrics.NewGHCRRegistry(baseRegistry)
142+
143+
// Test all metrics with their expected labels
144+
testCases := []struct {
145+
name string
146+
metric *prometheus.CounterVec
147+
labels prometheus.Labels
148+
description string
149+
}{
150+
{
151+
name: "CollectionFailedCounter",
152+
metric: registry.CollectionFailedCounter,
153+
labels: prometheus.Labels{"repo": "test-repo", "interval": "30"},
154+
description: "Should accept 'repo' and 'interval' labels",
155+
},
156+
{
157+
name: "CollectionSuccessCounter",
158+
metric: registry.CollectionSuccessCounter,
159+
labels: prometheus.Labels{"repo": "test-repo", "interval": "30"},
160+
description: "Should accept 'repo' and 'interval' labels",
161+
},
162+
}
163+
164+
for _, tc := range testCases {
165+
t.Run(tc.name, func(t *testing.T) {
166+
// This will panic if labels don't match the metric definition
167+
counter := tc.metric.With(tc.labels)
168+
counter.Inc()
169+
170+
// Verify the metric was created successfully
171+
value := testutil.ToFloat64(counter)
172+
if value != 1.0 {
173+
t.Errorf("Expected metric value 1.0, got %f", value)
174+
}
175+
176+
t.Logf("✅ %s: %s", tc.name, tc.description)
177+
})
178+
}
179+
180+
// Test gauge metrics
181+
gaugeTestCases := []struct {
182+
name string
183+
metric *prometheus.GaugeVec
184+
labels prometheus.Labels
185+
description string
186+
}{
187+
{
188+
name: "CollectionIntervalGauge",
189+
metric: registry.CollectionIntervalGauge,
190+
labels: prometheus.Labels{"repo": "test-repo", "interval": "30"},
191+
description: "Should accept 'repo' and 'interval' labels",
192+
},
193+
{
194+
name: "CollectionDurationGauge",
195+
metric: registry.CollectionDurationGauge,
196+
labels: prometheus.Labels{"repo": "test-repo", "interval": "30"},
197+
description: "Should accept 'repo' and 'interval' labels",
198+
},
199+
{
200+
name: "CollectionTimestampGauge",
201+
metric: registry.CollectionTimestampGauge,
202+
labels: prometheus.Labels{"repo": "test-repo", "interval": "30"},
203+
description: "Should accept 'repo' and 'interval' labels",
204+
},
205+
{
206+
name: "PackageDownloadsGauge",
207+
metric: registry.PackageDownloadsGauge,
208+
labels: prometheus.Labels{"owner": "test-owner", "repo": "test-repo"},
209+
description: "Should accept 'owner' and 'repo' labels",
210+
},
211+
{
212+
name: "PackageLastPublishedGauge",
213+
metric: registry.PackageLastPublishedGauge,
214+
labels: prometheus.Labels{"owner": "test-owner", "repo": "test-repo"},
215+
description: "Should accept 'owner' and 'repo' labels",
216+
},
217+
{
218+
name: "PackageDownloadStatsGauge",
219+
metric: registry.PackageDownloadStatsGauge,
220+
labels: prometheus.Labels{"owner": "test-owner", "repo": "test-repo"},
221+
description: "Should accept 'owner' and 'repo' labels",
222+
},
223+
}
224+
225+
for _, tc := range gaugeTestCases {
226+
t.Run(tc.name, func(t *testing.T) {
227+
// This will panic if labels don't match the metric definition
228+
gauge := tc.metric.With(tc.labels)
229+
gauge.Set(42.0)
230+
231+
// Verify the metric was created successfully
232+
value := testutil.ToFloat64(gauge)
233+
if value != 42.0 {
234+
t.Errorf("Expected metric value 42.0, got %f", value)
235+
}
236+
237+
t.Logf("✅ %s: %s", tc.name, tc.description)
238+
})
239+
}
240+
241+
t.Log("✅ All metric label consistency tests passed")
242+
}
243+
244+
// TestGHCRCollectorErrorHandling tests error scenarios to ensure they don't cause label panics
245+
func TestGHCRCollectorErrorHandling(t *testing.T) {
246+
// Create test server that returns errors
247+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
248+
w.WriteHeader(http.StatusInternalServerError)
249+
}))
250+
defer server.Close()
251+
252+
// Create test configuration
253+
cfg := &config.Config{
254+
Packages: []config.PackageGroup{
255+
{
256+
Owner: "d0ugal",
257+
Repo: "filesystem-exporter",
258+
},
259+
},
260+
GitHub: config.GitHubConfig{
261+
Token: promexporter_config.NewSensitiveString("test-token"),
262+
},
263+
}
264+
265+
// Create a fresh registry for testing
266+
prometheus.DefaultRegisterer = prometheus.NewRegistry()
267+
baseRegistry := promexporter_metrics.NewRegistry("test_exporter_info")
268+
registry := metrics.NewGHCRRegistry(baseRegistry)
269+
270+
collector := NewGHCRCollector(cfg, registry)
271+
collector.client = server.Client()
272+
273+
// Test error handling without panicking
274+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
275+
defer cancel()
276+
277+
collector.Start(ctx)
278+
time.Sleep(100 * time.Millisecond)
279+
cancel()
280+
time.Sleep(100 * time.Millisecond)
281+
282+
// If we get here without panicking, error handling is working correctly
283+
t.Log("✅ Error handling works correctly without label panics")
284+
}

0 commit comments

Comments
 (0)