package client import ( "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "git.db.org.ai/dborg/internal/config" ) func testConfig(baseURL string) *config.Config { return &config.Config{ APIKey: "test-key", BaseURL: baseURL, Timeout: 5 * time.Second, MaxRetries: 3, UserAgent: "dborg-test", } } func TestRedactKey(t *testing.T) { tests := []struct { in, want string }{ {"", "***"}, {"short", "***"}, {"12345678", "***"}, {"abcd1234efgh5678", "abcd...5678"}, } for _, tt := range tests { if got := redactKey(tt.in); got != tt.want { t.Errorf("redactKey(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestIsRetryable(t *testing.T) { retryable := []int{429, 408, 500, 502, 503, 504} notRetryable := []int{200, 201, 301, 400, 401, 403, 404} for _, code := range retryable { if !isRetryable(code) { t.Errorf("isRetryable(%d) = false, want true", code) } } for _, code := range notRetryable { if isRetryable(code) { t.Errorf("isRetryable(%d) = true, want false", code) } } } func TestBackoffDelay_RetryAfterSeconds(t *testing.T) { d := backoffDelay(1, "5") if d != 5*time.Second { t.Errorf("backoffDelay with Retry-After=5 = %v, want 5s", d) } } func TestBackoffDelay_RetryAfterHTTPDate(t *testing.T) { future := time.Now().Add(10 * time.Second).UTC().Format(http.TimeFormat) d := backoffDelay(1, future) if d < 5*time.Second || d > 11*time.Second { t.Errorf("backoffDelay with HTTP date = %v, want ~10s", d) } } func TestBackoffDelay_Exponential(t *testing.T) { d1 := backoffDelay(1, "") d2 := backoffDelay(2, "") d3 := backoffDelay(3, "") if d1 < 2*time.Second || d1 > 3*time.Second { t.Errorf("attempt 1 delay = %v, want 2-3s", d1) } if d2 < 4*time.Second || d2 > 6*time.Second { t.Errorf("attempt 2 delay = %v, want 4-6s", d2) } if d3 < 8*time.Second || d3 > 12*time.Second { t.Errorf("attempt 3 delay = %v, want 8-12s", d3) } } func TestDoRequest_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-API-Key"); got != "test-key" { t.Errorf("X-API-Key = %q, want test-key", got) } fmt.Fprintln(w, `{"ok":true}`) })) defer srv.Close() c, _ := New(testConfig(srv.URL)) body, err := c.Get("/test", nil) if err != nil { t.Fatalf("Get() error = %v", err) } if !strings.Contains(string(body), `"ok":true`) { t.Errorf("unexpected body: %s", body) } } func TestDoRequest_RetriesOn500ThenSucceeds(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt32(&calls, 1) if n < 3 { http.Error(w, "boom", http.StatusInternalServerError) return } fmt.Fprintln(w, `{"ok":true}`) })) defer srv.Close() cfg := testConfig(srv.URL) cfg.MaxRetries = 5 c, _ := New(cfg) body, err := c.Get("/test", nil) if err != nil { t.Fatalf("Get() error = %v", err) } if atomic.LoadInt32(&calls) != 3 { t.Errorf("calls = %d, want 3", calls) } if !strings.Contains(string(body), `"ok":true`) { t.Errorf("unexpected body: %s", body) } } func TestDoRequest_NoRetryOn400(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) http.Error(w, "bad", http.StatusBadRequest) })) defer srv.Close() c, _ := New(testConfig(srv.URL)) _, err := c.Get("/test", nil) if err == nil { t.Fatal("expected error") } if atomic.LoadInt32(&calls) != 1 { t.Errorf("calls = %d, want 1 (no retries on 400)", calls) } } func TestDoRequest_NoRetryOn401(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) http.Error(w, "no auth", http.StatusUnauthorized) })) defer srv.Close() c, _ := New(testConfig(srv.URL)) _, err := c.Get("/test", nil) if err == nil { t.Fatal("expected error") } if atomic.LoadInt32(&calls) != 1 { t.Errorf("calls = %d, want 1", calls) } } func TestDoRequest_ExhaustsRetries(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) http.Error(w, "down", http.StatusBadGateway) })) defer srv.Close() cfg := testConfig(srv.URL) cfg.MaxRetries = 2 c, _ := New(cfg) _, err := c.Get("/test", nil) if err == nil { t.Fatal("expected error after exhausting retries") } if atomic.LoadInt32(&calls) != 3 { t.Errorf("calls = %d, want 3 (initial + 2 retries)", calls) } } func TestDoRequest_PostBodyResentOnRetry(t *testing.T) { var calls int32 var bodies []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) bodies = append(bodies, string(b)) n := atomic.AddInt32(&calls, 1) if n < 2 { http.Error(w, "boom", http.StatusServiceUnavailable) return } fmt.Fprintln(w, `{"ok":true}`) })) defer srv.Close() c, _ := New(testConfig(srv.URL)) payload := map[string]string{"hello": "world"} _, err := c.Post("/test", payload) if err != nil { t.Fatalf("Post() error = %v", err) } if len(bodies) != 2 { t.Fatalf("got %d requests, want 2", len(bodies)) } if bodies[0] != bodies[1] { t.Errorf("body mismatch between retries:\n first: %q\n second: %q", bodies[0], bodies[1]) } if !strings.Contains(bodies[0], `"hello":"world"`) { t.Errorf("unexpected body: %q", bodies[0]) } } func TestDoRequest_RetryAfterHeaderHonored(t *testing.T) { var calls int32 var firstAt, secondAt time.Time srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt32(&calls, 1) if n == 1 { firstAt = time.Now() w.Header().Set("Retry-After", "1") http.Error(w, "slow down", http.StatusTooManyRequests) return } secondAt = time.Now() fmt.Fprintln(w, `{"ok":true}`) })) defer srv.Close() c, _ := New(testConfig(srv.URL)) _, err := c.Get("/test", nil) if err != nil { t.Fatalf("Get() error = %v", err) } gap := secondAt.Sub(firstAt) if gap < 900*time.Millisecond { t.Errorf("retry gap = %v, want >= 1s (Retry-After honored)", gap) } }