master
go 658 lines 21.9 KB
Raw
1 package main
2
3 import (
4 "bytes"
5 "context" // Import context
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 // Import promhttp for testing the metrics endpoint handler
19 "github.com/prometheus/client_golang/prometheus/promhttp"
20 )
21
22 // Helper function to capture stdout/stderr during a test run
23 func captureOutput(t *testing.T, f func()) (stdout, stderr string) {
24 t.Helper() // Marks this as a helper function for testing framework
25
26 originalStdout := os.Stdout
27 originalStderr := os.Stderr
28 oldLogger := slog.Default()
29 rOut, wOut, _ := os.Pipe()
30 rErr, wErr, _ := os.Pipe()
31 os.Stdout = wOut
32 os.Stderr = wErr
33
34 t.Cleanup(func() {
35 os.Stdout = originalStdout
36 os.Stderr = originalStderr
37 slog.SetDefault(oldLogger)
38 })
39
40 outCh := make(chan string)
41 errCh := make(chan string)
42
43 go func() {
44 var buf bytes.Buffer
45 _, _ = io.Copy(&buf, rOut)
46 outCh <- buf.String()
47 }()
48 go func() {
49 var buf bytes.Buffer
50 _, _ = io.Copy(&buf, rErr)
51 errCh <- buf.String()
52 }()
53
54 f() // Execute the function
55
56 _ = wOut.Close()
57 _ = wErr.Close()
58 stdout = <-outCh
59 stderr = <-errCh
60 return stdout, stderr
61 }
62
63 // --- Test Suite ---
64
65 // setupTest initializes necessary components for tests
66 func setupTest(t *testing.T) {
67 t.Helper()
68 keyPaths = []string{"id"}
69 dedupSeparator = "-"
70 dedupWindow = 30 * time.Second
71 startTime = time.Now()
72 noopHandler := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})
73 slog.SetDefault(slog.New(noopHandler))
74
75 initMetrics() // Initializes OTEL which feeds default registry
76
77 mapMutex.Lock()
78 seenIDs = make(map[[32]byte]seenEntry)
79 mapMutex.Unlock()
80
81 t.Cleanup(func() {
82 if meterProvider != nil {
83 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
84 defer cancel()
85 if err := meterProvider.Shutdown(ctx); err != nil {
86 t.Logf("Warning: error shutting down meter provider in test cleanup: %v", err)
87 }
88 }
89 })
90 }
91
92 // Helper to reset state between sub-tests if needed beyond setupTest
93 func resetDedupState() {
94 mapMutex.Lock()
95 seenIDs = make(map[[32]byte]seenEntry)
96 mapMutex.Unlock()
97 keyPaths = []string{"id"}
98 dedupSeparator = "-"
99 }
100
101 func TestHandler(t *testing.T) {
102 setupTest(t) // Setup once for all sub-tests
103
104 // --- Test Cases ---
105
106 t.Run("FirstValidRequest", func(t *testing.T) {
107 t.Cleanup(resetDedupState) // Reset map for isolation
108 jsonBody := `{"id": "uuid-1", "data": "value1"}`
109 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
110 rr := httptest.NewRecorder()
111 stdout, _ := captureOutput(t, func() { handler(rr, req) })
112 if status := rr.Code; status != http.StatusOK {
113 t.Errorf("status: got %v want %v", status, http.StatusOK)
114 }
115 if rr.Body.String() != "OK" {
116 t.Errorf("body: got %v want %v", rr.Body.String(), "OK")
117 }
118 if !strings.Contains(stdout, `"id":"uuid-1"`) {
119 t.Errorf("stdout missing id: %q", stdout)
120 }
121 mapMutex.Lock()
122 mapLen := len(seenIDs)
123 mapMutex.Unlock()
124 if mapLen != 1 {
125 t.Errorf("map size: got %d want 1", mapLen)
126 }
127 })
128
129 t.Run("DuplicateRequestWithinWindow", func(t *testing.T) {
130 t.Cleanup(resetDedupState)
131 firstJsonBody := `{"id": "uuid-2", "data": "value2"}`
132 firstReq := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(firstJsonBody))
133 firstRr := httptest.NewRecorder()
134 captureOutput(t, func() { handler(firstRr, firstReq) })
135 if firstRr.Code != http.StatusOK {
136 t.Fatalf("Setup failed")
137 }
138 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(firstJsonBody))
139 rr := httptest.NewRecorder()
140 stdout, _ := captureOutput(t, func() { handler(rr, req) })
141 if status := rr.Code; status != http.StatusOK {
142 t.Errorf("status: got %v want %v", status, http.StatusOK)
143 }
144 if rr.Body.String() != "OK" {
145 t.Errorf("body: got %v want %v", rr.Body.String(), "OK")
146 }
147 if stdout != "" {
148 t.Errorf("stdout not empty: %q", stdout)
149 }
150 mapMutex.Lock()
151 mapLen := len(seenIDs)
152 mapMutex.Unlock()
153 if mapLen != 1 {
154 t.Errorf("map size: got %d want 1", mapLen)
155 }
156 })
157
158 t.Run("InvalidJSON", func(t *testing.T) {
159 t.Cleanup(resetDedupState)
160 jsonBody := `{"id": "uuid-3", "data":`
161 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
162 rr := httptest.NewRecorder()
163 stdout, _ := captureOutput(t, func() { handler(rr, req) })
164 if status := rr.Code; status != http.StatusBadRequest {
165 t.Errorf("status: got %v want %v", status, http.StatusBadRequest)
166 }
167 if stdout != "" {
168 t.Errorf("stdout not empty: %q", stdout)
169 }
170 })
171
172 t.Run("MissingDedupKey", func(t *testing.T) {
173 t.Cleanup(resetDedupState)
174 jsonBody := `{"other_id": "uuid-4", "data": "value4"}`
175 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
176 rr := httptest.NewRecorder()
177 stdout, _ := captureOutput(t, func() { handler(rr, req) })
178 if status := rr.Code; status != http.StatusOK {
179 t.Errorf("status: got %v want %v", status, http.StatusOK)
180 }
181 if !strings.Contains(stdout, `"other_id":"uuid-4"`) {
182 t.Errorf("stdout missing other_id: %q", stdout)
183 }
184 mapMutex.Lock()
185 mapLen := len(seenIDs)
186 mapMutex.Unlock()
187 if mapLen != 1 {
188 t.Errorf("map size: got %d want 1", mapLen)
189 }
190 })
191
192 t.Run("CloudflareHeaders", func(t *testing.T) {
193 t.Cleanup(resetDedupState)
194 jsonBody := `{"id": "uuid-cf", "data": "value-cf"}`
195 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
196 // Set allowed headers (non-IP related)
197 req.Header.Set("CF-IPCountry", "US")
198 req.Header.Set("CF-Ray", "123")
199 req.Header.Set("CF-IPCity", "Testville")
200 // Set IP-related headers that should be excluded for GDPR compliance
201 req.Header.Set("CF-Connecting-IP", "1.2.3.4")
202 req.Header.Set("CF-IPLatitude", "12.34")
203 req.Header.Set("CF-IPLongitude", "-56.78")
204 req.Header.Set("CF-Visitor", "{\"ip\":\"1.2.3.4\"}")
205
206 rr := httptest.NewRecorder()
207 stdout, _ := captureOutput(t, func() { handler(rr, req) })
208 if status := rr.Code; status != http.StatusOK {
209 t.Errorf("status: got %v want %v", status, http.StatusOK)
210 }
211 if !strings.Contains(stdout, `"cf":`) {
212 t.Errorf("stdout missing cf object")
213 }
214 if !strings.Contains(stdout, `"IPCountry":"US"`) {
215 t.Errorf("stdout missing cf header IPCountry")
216 }
217
218 // Verify IP-related headers are excluded for GDPR compliance
219 if strings.Contains(stdout, `"Connecting-IP"`) {
220 t.Errorf("stdout should not contain IP address: Connecting-IP")
221 }
222 if strings.Contains(stdout, `"IPLatitude"`) {
223 t.Errorf("stdout should not contain IP geolocation: IPLatitude")
224 }
225 if strings.Contains(stdout, `"IPLongitude"`) {
226 t.Errorf("stdout should not contain IP geolocation: IPLongitude")
227 }
228 if strings.Contains(stdout, `"Visitor"`) {
229 t.Errorf("stdout should not contain Visitor which includes IP")
230 }
231 })
232
233 t.Run("MethodNotAllowed", func(t *testing.T) {
234 t.Cleanup(resetDedupState)
235 req := httptest.NewRequest(http.MethodGet, "/", nil)
236 rr := httptest.NewRecorder()
237 stdout, _ := captureOutput(t, func() { handler(rr, req) })
238 if status := rr.Code; status != http.StatusMethodNotAllowed {
239 t.Errorf("status: got %v want %v", status, http.StatusMethodNotAllowed)
240 }
241 if stdout != "" {
242 t.Errorf("stdout not empty: %q", stdout)
243 }
244 })
245
246 t.Run("RequestEntityTooLarge", func(t *testing.T) {
247 t.Cleanup(resetDedupState)
248 largeBody := make([]byte, maxRequestBodySize+1)
249 req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(largeBody))
250 rr := httptest.NewRecorder()
251 stdout, _ := captureOutput(t, func() { handler(rr, req) })
252 if status := rr.Code; status != http.StatusRequestEntityTooLarge {
253 t.Errorf("status: got %v want %v", status, http.StatusRequestEntityTooLarge)
254 }
255 if stdout != "" {
256 t.Errorf("stdout not empty: %q", stdout)
257 }
258 })
259
260 t.Run("MultiKeyDeduplication", func(t *testing.T) {
261 t.Cleanup(resetDedupState)
262 keyPaths = []string{"id", "source"}
263 dedupSeparator = "|"
264 req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "1", "source": "A", "data": "v1"}`))
265 rr1 := httptest.NewRecorder()
266 stdout1, _ := captureOutput(t, func() { handler(rr1, req1) })
267 if rr1.Code != http.StatusOK || !strings.Contains(stdout1, `"id":"1"`) {
268 t.Errorf("Request 1 failed")
269 }
270 req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "1", "source": "B", "data": "v2"}`))
271 rr2 := httptest.NewRecorder()
272 stdout2, _ := captureOutput(t, func() { handler(rr2, req2) })
273 if rr2.Code != http.StatusOK || !strings.Contains(stdout2, `"source":"B"`) {
274 t.Errorf("Request 2 failed")
275 }
276 req3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "1", "source": "A", "data": "v3"}`))
277 rr3 := httptest.NewRecorder()
278 stdout3, _ := captureOutput(t, func() { handler(rr3, req3) })
279 if rr3.Code != http.StatusOK {
280 t.Errorf("Request 3 status wrong")
281 }
282 if stdout3 != "" {
283 t.Errorf("Request 3 produced output")
284 }
285 mapMutex.Lock()
286 mapLen := len(seenIDs)
287 mapMutex.Unlock()
288 if mapLen != 2 {
289 t.Errorf("map size: got %d want 2", mapLen)
290 }
291 })
292
293 t.Run("MetricsDelta", func(t *testing.T) {
294 t.Skip("Skipping MetricsDelta test: Verifying exact metric deltas with OTEL in unit tests is complex.")
295 })
296
297 t.Run("HealthEndpoint", func(t *testing.T) {
298 t.Cleanup(resetDedupState)
299 req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
300 rr := httptest.NewRecorder()
301 healthHandler(rr, req)
302 if status := rr.Code; status != http.StatusOK {
303 t.Errorf("status: got %v want %v", status, http.StatusOK)
304 }
305 if contentType := rr.Header().Get("Content-Type"); contentType != "application/json" {
306 t.Errorf("content type: got %v want %v", contentType, "application/json")
307 }
308 var result map[string]interface{}
309 if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
310 t.Fatalf("invalid JSON: %v", err)
311 }
312 requiredFields := []string{"status", "timestamp", "uptime", "goroutines", "memory", "deduplication"}
313 for _, field := range requiredFields {
314 if _, ok := result[field]; !ok {
315 t.Errorf("missing field: %s", field)
316 }
317 }
318 if status, ok := result["status"].(string); !ok || status != "ok" {
319 t.Errorf("status field: got %v want ok", result["status"])
320 }
321 })
322
323 t.Run("MetricsEndpoint", func(t *testing.T) {
324 // Re-enabled: Uses promhttp.Handler which reads from default registry
325 t.Cleanup(resetDedupState)
326
327 // Create test server using the standard promhttp handler
328 metricsServer := httptest.NewServer(promhttp.Handler())
329 t.Cleanup(metricsServer.Close)
330
331 // Make requests to main handler to generate metrics
332 captureOutput(t, func() {
333 handler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "m1"}`)))
334 })
335 captureOutput(t, func() {
336 handler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "m1"}`))) // duplicate
337 })
338 captureOutput(t, func() {
339 handler(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) // method not allowed
340 })
341
342 // Fetch metrics
343 resp, err := http.Get(metricsServer.URL)
344 if err != nil {
345 t.Fatalf("failed to get metrics: %v", err)
346 }
347 defer resp.Body.Close()
348
349 if status := resp.StatusCode; status != http.StatusOK {
350 t.Errorf("status: got %v want %v", status, http.StatusOK)
351 }
352 if contentType := resp.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
353 t.Errorf("content type: got %q want prefix text/plain", contentType)
354 }
355
356 metricsBodyBytes, err := io.ReadAll(resp.Body)
357 if err != nil {
358 t.Fatalf("failed to read metrics body: %v", err)
359 }
360 metricsContent := string(metricsBodyBytes)
361 t.Logf("Metrics Output for Verification:\n%s", metricsContent) // Log for manual inspection if needed
362
363 // Check for presence of key metrics (handling both standard and suffixed names)
364 // OpenTelemetry may add suffixes like _ratio_total to counter metrics
365 metricChecks := []struct {
366 namePatterns []string
367 description string
368 }{
369 {
370 namePatterns: []string{"agent_events_requests", "agent_events_requests_ratio_total"},
371 description: "Requests counter",
372 },
373 {
374 namePatterns: []string{"agent_events_received_bytes", "agent_events_received_bytes_ratio_total"},
375 description: "Bytes received counter",
376 },
377 {
378 namePatterns: []string{"agent_events_dedup_cache_entries"},
379 description: "Dedup cache size gauge",
380 },
381 {
382 namePatterns: []string{"agent_events_request_duration_seconds"},
383 description: "Request duration histogram",
384 },
385 {
386 namePatterns: []string{"go_goroutines"},
387 description: "Go runtime metrics",
388 },
389 }
390
391 for _, check := range metricChecks {
392 found := false
393 for _, pattern := range check.namePatterns {
394 if strings.Contains(metricsContent, pattern) {
395 found = true
396 break
397 }
398 }
399 if !found {
400 t.Errorf("metrics response missing expected metric: %s (patterns: %v)", check.description, check.namePatterns)
401 }
402 }
403
404 // OpenTelemetry histogram metrics have this pattern in the output:
405 // agent_events_request_duration_seconds_bucket{...
406 // agent_events_request_duration_seconds_sum{...
407 // agent_events_request_duration_seconds_count{...
408 // So check for existence, not value line which can be variable
409 if !strings.Contains(metricsContent, "agent_events_request_duration_seconds_count{") {
410 t.Errorf("metrics response missing count line for histogram metric")
411 }
412 })
413
414 t.Run("DedupWindowExpiration", func(t *testing.T) {
415 t.Cleanup(resetDedupState)
416 oldWindow := dedupWindow
417 dedupWindow = 50 * time.Millisecond
418 t.Cleanup(func() { dedupWindow = oldWindow })
419 req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "exp1"}`))
420 rr1 := httptest.NewRecorder()
421 stdout1, _ := captureOutput(t, func() { handler(rr1, req1) })
422 if rr1.Code != http.StatusOK || !strings.Contains(stdout1, "exp1") {
423 t.Errorf("Req 1 failed")
424 }
425 req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "exp1"}`))
426 rr2 := httptest.NewRecorder()
427 stdout2, _ := captureOutput(t, func() { handler(rr2, req2) })
428 if stdout2 != "" {
429 t.Errorf("Immediate duplicate not suppressed")
430 }
431 time.Sleep(100 * time.Millisecond)
432 req3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "exp1"}`))
433 rr3 := httptest.NewRecorder()
434 stdout3, _ := captureOutput(t, func() { handler(rr3, req3) })
435 if rr3.Code != http.StatusOK || !strings.Contains(stdout3, "exp1") {
436 t.Errorf("Req 3 after expiry failed")
437 }
438 })
439
440 t.Run("LongKeyValues", func(t *testing.T) {
441 t.Cleanup(resetDedupState)
442 longId := strings.Repeat("a", 500)
443 jsonBody := fmt.Sprintf(`{"id": "%s", "data": "long"}`, longId)
444 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
445 rr := httptest.NewRecorder()
446 stdout, _ := captureOutput(t, func() { handler(rr, req) })
447 if rr.Code != http.StatusOK || !strings.Contains(stdout, "long") {
448 t.Errorf("Long key req failed")
449 }
450 reqDup := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
451 rrDup := httptest.NewRecorder()
452 stdoutDup, _ := captureOutput(t, func() { handler(rrDup, reqDup) })
453 if stdoutDup != "" {
454 t.Errorf("Long key duplicate not suppressed")
455 }
456 })
457
458 t.Run("OptionsMethod", func(t *testing.T) {
459 t.Cleanup(resetDedupState)
460 req := httptest.NewRequest(http.MethodOptions, "/", nil)
461 rr := httptest.NewRecorder()
462 stdout, _ := captureOutput(t, func() { handler(rr, req) })
463 if status := rr.Code; status != http.StatusMethodNotAllowed {
464 t.Errorf("status: got %v want %v", status, http.StatusMethodNotAllowed)
465 }
466 if stdout != "" {
467 t.Errorf("stdout not empty: %q", stdout)
468 }
469 })
470
471 t.Run("VariousJSONFormats", func(t *testing.T) {
472 t.Cleanup(resetDedupState)
473 testCases := []struct {
474 name string
475 body string
476 expectStatus int
477 expectOutput bool
478 }{
479 {"EmptyObject", `{}`, http.StatusOK, true}, {"ValidJSON", `{"id": "valid"}`, http.StatusOK, true},
480 {"SingleQuotes", `{'id': 'invalid'}`, http.StatusBadRequest, false}, {"TrailingComma", `{"id": "comma",}`, http.StatusBadRequest, false},
481 {"UnquotedKey", `{id: "unquoted"}`, http.StatusBadRequest, false},
482 }
483 for _, tc := range testCases {
484 t.Run(tc.name, func(t *testing.T) {
485 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
486 rr := httptest.NewRecorder()
487 stdout, _ := captureOutput(t, func() { handler(rr, req) })
488 if status := rr.Code; status != tc.expectStatus {
489 t.Errorf("status: got %v want %v", status, tc.expectStatus)
490 }
491 hasOutput := stdout != ""
492 if hasOutput != tc.expectOutput {
493 t.Errorf("Output mismatch: expected %t, got %t (stdout: %q)", tc.expectOutput, hasOutput, stdout)
494 }
495 })
496 }
497 })
498
499 t.Run("ConcurrentRequests", func(t *testing.T) {
500 t.Cleanup(resetDedupState)
501 numRequests := 50
502 var wg sync.WaitGroup
503 wg.Add(numRequests)
504 process := func(id int) {
505 defer wg.Done()
506 jsonBody := fmt.Sprintf(`{"id": "conc-%d"}`, id)
507 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
508 rr := httptest.NewRecorder()
509 captureOutput(t, func() { handler(rr, req) })
510 if status := rr.Code; status != http.StatusOK {
511 t.Logf("conc req %d status: got %v want %v", id, status, http.StatusOK)
512 t.Fail()
513 }
514 }
515 for i := 0; i < numRequests; i++ {
516 go process(i)
517 }
518 wg.Wait()
519 mapMutex.Lock()
520 mapLen := len(seenIDs)
521 mapMutex.Unlock()
522 if mapLen != numRequests {
523 t.Errorf("map size: got %d want %d", mapLen, numRequests)
524 }
525 })
526
527 t.Run("CleanupExpiredEntries", func(t *testing.T) {
528 t.Cleanup(resetDedupState)
529 oldWindow := dedupWindow
530 dedupWindow = 50 * time.Millisecond
531 t.Cleanup(func() { dedupWindow = oldWindow })
532 numEntries := 5
533 for i := 0; i < numEntries; i++ {
534 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"id": "clean-%d"}`, i)))
535 rr := httptest.NewRecorder()
536 captureOutput(t, func() { handler(rr, req) })
537 if rr.Code != http.StatusOK {
538 t.Fatalf("Setup failed entry %d", i)
539 }
540 }
541 mapMutex.Lock()
542 if got := len(seenIDs); got != numEntries {
543 t.Fatalf("Entries after add: %d != %d", got, numEntries)
544 }
545 mapMutex.Unlock()
546 time.Sleep(100 * time.Millisecond)
547 now := time.Now()
548 mapMutex.Lock()
549 for h, entry := range seenIDs {
550 if now.Sub(entry.timestamp) >= dedupWindow {
551 delete(seenIDs, h)
552 }
553 }
554 count := len(seenIDs)
555 mapMutex.Unlock()
556 if count != 0 {
557 t.Errorf("Entries after cleanup: %d != 0", count)
558 }
559 })
560
561 t.Run("MixedKeyTypes", func(t *testing.T) {
562 t.Cleanup(resetDedupState)
563 keyPaths = []string{"id", "count", "enabled"}
564 dedupSeparator = "|"
565 req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "mix", "count": 1, "enabled": true}`))
566 rr1 := httptest.NewRecorder()
567 stdout1, _ := captureOutput(t, func() { handler(rr1, req1) })
568 if rr1.Code != http.StatusOK || !strings.Contains(stdout1, `"id":"mix"`) {
569 t.Errorf("Req 1 failed")
570 }
571 req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"enabled": true, "count": 1.0, "id": "mix"}`))
572 rr2 := httptest.NewRecorder()
573 stdout2, _ := captureOutput(t, func() { handler(rr2, req2) })
574 if rr2.Code != http.StatusOK {
575 t.Errorf("Req 2 status wrong")
576 }
577 if stdout2 != "" {
578 t.Errorf("Req 2 produced output")
579 }
580 req3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"id": "mix", "count": 1, "enabled": false}`))
581 rr3 := httptest.NewRecorder()
582 stdout3, _ := captureOutput(t, func() { handler(rr3, req3) })
583 if rr3.Code != http.StatusOK || !strings.Contains(stdout3, `"enabled":false`) {
584 t.Errorf("Req 3 failed")
585 }
586 mapMutex.Lock()
587 mapLen := len(seenIDs)
588 mapMutex.Unlock()
589 if mapLen != 2 {
590 t.Errorf("map size: got %d want 2", mapLen)
591 }
592 })
593
594 t.Run("JsonFormatTests", func(t *testing.T) {
595 t.Cleanup(resetDedupState)
596 testCases := []struct {
597 name string
598 body string
599 expectStatus int
600 expectOutput bool
601 }{
602 {"EmptyObject", `{}`, http.StatusOK, true}, {"ValidJSON", `{"id": "valid"}`, http.StatusOK, true},
603 {"CompletelyInvalid", `not json`, http.StatusBadRequest, false}, {"IncompleteJSON", `{"id": "inc`, http.StatusBadRequest, false},
604 {"ArrayAsRoot", `[1, 2]`, http.StatusBadRequest, false}, // Expect 400 now
605 }
606 for _, tc := range testCases {
607 t.Run(tc.name, func(t *testing.T) {
608 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
609 rr := httptest.NewRecorder()
610 stdout, _ := captureOutput(t, func() { handler(rr, req) })
611 if status := rr.Code; status != tc.expectStatus {
612 t.Errorf("status: got %v want %v", status, tc.expectStatus)
613 }
614 hasOutput := stdout != ""
615 if hasOutput != tc.expectOutput {
616 t.Errorf("Output mismatch: expected %t, got %t", tc.expectOutput, hasOutput)
617 }
618 })
619 }
620 })
621
622 t.Run("MalformedJsonHandling", func(t *testing.T) {
623 // Verifies server returns BadRequest for invalid JSON
624 t.Cleanup(resetDedupState)
625 req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`not json`))
626 rr := httptest.NewRecorder()
627 captureOutput(t, func() { handler(rr, req) })
628 if status := rr.Code; status != http.StatusBadRequest {
629 t.Errorf("status invalid: got %v want %v", status, http.StatusBadRequest)
630 }
631 req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"open":`))
632 rr2 := httptest.NewRecorder()
633 captureOutput(t, func() { handler(rr2, req2) })
634 if status := rr2.Code; status != http.StatusBadRequest {
635 t.Errorf("status incomplete: got %v want %v", status, http.StatusBadRequest)
636 }
637 })
638
639 t.Run("ZeroLengthDedupWindow", func(t *testing.T) {
640 t.Cleanup(resetDedupState)
641 oldWindow := dedupWindow
642 dedupWindow = 0
643 t.Cleanup(func() { dedupWindow = oldWindow })
644 jsonBody := `{"id": "zero"}`
645 req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
646 rr1 := httptest.NewRecorder()
647 stdout1, _ := captureOutput(t, func() { handler(rr1, req1) })
648 if rr1.Code != http.StatusOK || !strings.Contains(stdout1, "zero") {
649 t.Errorf("Req 1 failed")
650 }
651 req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody))
652 rr2 := httptest.NewRecorder()
653 stdout2, _ := captureOutput(t, func() { handler(rr2, req2) })
654 if rr2.Code != http.StatusOK || !strings.Contains(stdout2, "zero") {
655 t.Errorf("Req 2 (duplicate) failed")
656 }
657 })
658 }