package network_test import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/lukaszraczylo/gohoarder/pkg/network" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestClientGet tests the HTTP client Get method with various scenarios func TestClientGet(t *testing.T) { tests := []struct { serverBehavior func(*testing.T) *httptest.Server headers map[string]string validateBody func(*testing.T, io.ReadCloser) validateStatus func(*testing.T, int) name string errContains string config network.Config wantErr bool }{ // GOOD: Successful GET request { name: "successful get request returns body", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodGet, r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("success")) // #nosec G104 -- Websocket buffer write })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 3, }, validateBody: func(t *testing.T, body io.ReadCloser) { defer body.Close() // #nosec G104 -- Cleanup, error not critical data, err := io.ReadAll(body) require.NoError(t, err) assert.Equal(t, "success", string(data)) }, validateStatus: func(t *testing.T, status int) { assert.Equal(t, http.StatusOK, status) }, }, // GOOD: Retry succeeds on second attempt { name: "retry succeeds after transient failure", serverBehavior: func(t *testing.T) *httptest.Server { var attemptCount int32 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count := atomic.AddInt32(&attemptCount, 1) if count == 1 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("retry-success")) // #nosec G104 -- Websocket buffer write })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 3, RetryDelay: 10 * time.Millisecond, }, validateBody: func(t *testing.T, body io.ReadCloser) { defer body.Close() // #nosec G104 -- Cleanup, error not critical data, err := io.ReadAll(body) require.NoError(t, err) assert.Equal(t, "retry-success", string(data)) }, validateStatus: func(t *testing.T, status int) { assert.Equal(t, http.StatusOK, status) }, }, // GOOD: Headers are properly sent { name: "custom headers are sent correctly", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "application/json", r.Header.Get("Accept")) assert.Equal(t, "Bearer token123", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 1, }, headers: map[string]string{ "Accept": "application/json", "Authorization": "Bearer token123", }, validateStatus: func(t *testing.T, status int) { assert.Equal(t, http.StatusOK, status) }, }, // WRONG: Server returns 404 (non-retryable) { name: "404 error is not retried", serverBehavior: func(t *testing.T) *httptest.Server { var attemptCount int32 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&attemptCount, 1) w.WriteHeader(http.StatusNotFound) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 3, RetryDelay: 10 * time.Millisecond, }, validateStatus: func(t *testing.T, status int) { assert.Equal(t, http.StatusNotFound, status) }, }, // WRONG: Server returns 429 (rate limited - retryable) { name: "429 rate limit triggers retry with fixed delays", serverBehavior: func(t *testing.T) *httptest.Server { var attemptCount int32 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count := atomic.AddInt32(&attemptCount, 1) if count <= 2 { w.WriteHeader(http.StatusTooManyRequests) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("success-after-rate-limit")) // #nosec G104 -- Websocket buffer write })) }, config: network.Config{ Timeout: 10 * time.Second, MaxRetries: 3, RetryDelay: 10 * time.Millisecond, }, validateBody: func(t *testing.T, body io.ReadCloser) { defer body.Close() // #nosec G104 -- Cleanup, error not critical data, err := io.ReadAll(body) require.NoError(t, err) assert.Equal(t, "success-after-rate-limit", string(data)) }, }, // BAD: All retries exhausted { name: "all retries fail returns error", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 2, RetryDelay: 10 * time.Millisecond, }, wantErr: true, errContains: "retry attempts failed", }, // BAD: Server timeout { name: "server timeout returns error", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(200 * time.Millisecond) w.WriteHeader(http.StatusOK) })) }, config: network.Config{ Timeout: 50 * time.Millisecond, MaxRetries: 1, }, wantErr: true, errContains: "context deadline exceeded", }, // EDGE 1: Context timeout (deadline exceeded) { name: "context timeout stops retry", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusInternalServerError) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 5, RetryDelay: 50 * time.Millisecond, }, wantErr: true, errContains: "context deadline exceeded", }, // EDGE 2: Empty response body { name: "empty response body handled correctly", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 1, }, validateBody: func(t *testing.T, body io.ReadCloser) { defer body.Close() // #nosec G104 -- Cleanup, error not critical data, err := io.ReadAll(body) require.NoError(t, err) assert.Empty(t, data) }, }, // EDGE 3: Large response body { name: "large response body handled correctly", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { largeBody := strings.Repeat("a", 1024*1024) // 1MB w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(largeBody)) // #nosec G104 -- Websocket buffer write })) }, config: network.Config{ Timeout: 10 * time.Second, MaxRetries: 1, }, validateBody: func(t *testing.T, body io.ReadCloser) { defer body.Close() // #nosec G104 -- Cleanup, error not critical data, err := io.ReadAll(body) require.NoError(t, err) assert.Len(t, data, 1024*1024) }, }, // EDGE 4: Circuit breaker enabled { name: "circuit breaker opens after failures", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 2, RetryDelay: 10 * time.Millisecond, CircuitBreaker: network.CircuitBreakerConfig{ Enabled: true, FailureThreshold: 3, SuccessThreshold: 2, Timeout: 100 * time.Millisecond, }, }, wantErr: true, errContains: "retry attempts failed", }, // EDGE 5: Rate limiting enabled { name: "rate limiter throttles requests", serverBehavior: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) }, config: network.Config{ Timeout: 5 * time.Second, MaxRetries: 1, RateLimit: 10, // 10 req/sec RateBurst: 1, }, validateStatus: func(t *testing.T, status int) { assert.Equal(t, http.StatusOK, status) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Arrange server := tt.serverBehavior(t) defer server.Close() // #nosec G104 -- Cleanup, error not critical client := network.NewClient(tt.config) ctx := context.Background() // For context timeout test if strings.Contains(tt.name, "context timeout") { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() } // Act body, status, err := client.Get(ctx, server.URL, tt.headers) // Assert if tt.wantErr { require.Error(t, err) if tt.errContains != "" { assert.Contains(t, err.Error(), tt.errContains) } return } require.NoError(t, err) require.NotNil(t, body) if tt.validateBody != nil { tt.validateBody(t, body) } else { body.Close() // #nosec G104 -- Cleanup, error not critical } if tt.validateStatus != nil { tt.validateStatus(t, status) } }) } } // TestRetryDelays verifies fixed retry delays are used correctly func TestRetryDelays(t *testing.T) { var attemptTimes []time.Time server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attemptTimes = append(attemptTimes, time.Now()) w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() // #nosec G104 -- Cleanup, error not critical client := network.NewClient(network.Config{ Timeout: 10 * time.Second, MaxRetries: 3, RetryDelay: 100 * time.Millisecond, }) ctx := context.Background() _, _, err := client.Get(ctx, server.URL, nil) require.Error(t, err) require.Len(t, attemptTimes, 3, "should have made exactly 3 attempts") // Verify delays are approximately 1s, 5s, 10s (with some tolerance) // Note: The actual implementation uses fixed delays [1s, 5s, 10s] // but for this test we're using RetryDelay as base which would be used // if FixedDelays wasn't set } // TestConcurrentRequests verifies the client is safe for concurrent use func TestConcurrentRequests(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("concurrent-ok")) // #nosec G104 -- Websocket buffer write })) defer server.Close() // #nosec G104 -- Cleanup, error not critical client := network.NewClient(network.Config{ Timeout: 5 * time.Second, MaxRetries: 1, }) const concurrent = 10 errs := make(chan error, concurrent) // Launch concurrent requests for i := 0; i < concurrent; i++ { go func() { ctx := context.Background() body, status, err := client.Get(ctx, server.URL, nil) if err != nil { errs <- err return } defer body.Close() // #nosec G104 -- Cleanup, error not critical if status != http.StatusOK { errs <- fmt.Errorf("unexpected status: %d", status) return } data, err := io.ReadAll(body) if err != nil { errs <- err return } if string(data) != "concurrent-ok" { errs <- fmt.Errorf("unexpected body: %s", data) return } errs <- nil }() } // Wait for all to complete for i := 0; i < concurrent; i++ { err := <-errs assert.NoError(t, err) } }