package yandex import ( "encoding/json" "fmt" "strings" "testing" "time" ) func TestTokenBucket(t *testing.T) { // Test token bucket with capacity 5, refill 1 per second tb := newTokenBucket(5, 1) // 1 token per second // Should immediately acquire tokens for i := 0; i < 5; i++ { if err := tb.acquire(); err != nil { t.Fatalf("expected no error, got: %v", err) } } // 6th acquire should fail (rate limited) if err := tb.acquire(); err == nil { t.Error("expected rate limit error on 6th acquire, got nil") } // Wait for refill and should succeed time.Sleep(1*time.Second + 10*time.Millisecond) if err := tb.acquire(); err != nil { t.Fatalf("expected to acquire after refill, got: %v", err) } } func TestTokenBucketCapacity(t *testing.T) { tb := newTokenBucket(3, 10) // 10 TPS, capacity 3 // Should start with 3 tokens if err := tb.acquire(); err != nil { t.Fatalf("expected success on first acquire, got: %v", err) } if err := tb.acquire(); err != nil { t.Fatalf("expected success on second acquire, got: %v", err) } if err := tb.acquire(); err != nil { t.Fatalf("expected success on third acquire, got: %v", err) } // 4th should fail if err := tb.acquire(); err == nil { t.Error("expected rate limit error on 4th acquire") } // Wait partial refill - should have some tokens back time.Sleep(500 * time.Millisecond) // May or may not have a token depending on refill math, but shouldn't panic _ = tb.acquire() } func TestCircuitBreakerClosed(t *testing.T) { cb := newCircuitBreaker() // Initially should be closed and allow requests for i := 0; i < 10; i++ { if !cb.allow() { t.Fatalf("expected circuit breaker to be closed and allow request %d", i) } } } func TestCircuitBreakerOpenAfterFailures(t *testing.T) { cb := newCircuitBreaker() // Record 3 failures to open the circuit (failThreshold = 3) for i := 0; i < 3; i++ { cb.recordFailure() } // Should now be open - allow() should return false (circuit open, requests rejected) if cb.allow() { t.Error("expected allow() to return false (circuit open), got true") } // Should have recorded the state transition if cb.state != open { t.Errorf("expected state open, got %v", cb.state) } // Test state transition to half-open by manually setting state and time cb.state = open cb.openSince = time.Now().Add(-31 * time.Second) // Should transition to half-open after timeout - allow() should return true if !cb.allow() { t.Error("expected allow() to return true after timeout") } if cb.state != halfOpen { t.Errorf("expected state halfOpen after timeout, got %v", cb.state) } } func TestCircuitBreakerRecordSuccess(t *testing.T) { cb := newCircuitBreaker() // Record 5 failures to open for i := 0; i < 5; i++ { cb.recordFailure() } if cb.state != open { t.Errorf("expected state open after 5 failures, got %v", cb.state) } // Record 3 successes in half-open state // First need to transition to half-open by waiting timeout, // but let's just test the success recording directly // by manually setting state cb.state = halfOpen cb.successes = 0 for i := 0; i < 3; i++ { cb.recordSuccess() } if cb.state != closed { t.Errorf("expected state closed after 3 successes from half-open, got %v", cb.state) } } func TestCircuitBreakerRecordFailureFromClosed(t *testing.T) { cb := newCircuitBreaker() // Record failures cb.recordFailure() cb.recordFailure() if cb.state != closed { t.Errorf("expected still closed after 2 failures, got %v", cb.state) } // 3rd failure should open cb.recordFailure() if cb.state != open { t.Errorf("expected open after 3rd failure, got %v", cb.state) } } func TestCircuitBreakerRecordSuccessFromHalfOpen(t *testing.T) { cb := newCircuitBreaker() // Simulate: 2 failures open the circuit, then 3 successes close it cb.recordFailure() cb.recordFailure() // state = open // Wait enough time to transition to half-open // (in real usage would wait the timeout duration) cb.state = halfOpen cb.successes = 0 cb.recordSuccess() cb.recordSuccess() cb.recordSuccess() if cb.state != closed { t.Errorf("expected closed after 3 successes from half-open, got %v", cb.state) } } func TestRetrySuccessAfterBackoff(t *testing.T) { // This tests the retry logic with a mock that fails then succeeds // We test the retry config and backoff timing cfg := &retryConfig{ maxRetries: 3, baseBackoff: 50 * time.Millisecond, maxBackoff: 2 * time.Second, jitter: false, } // Verify backoff sequence backoffs := []time.Duration{} for i := 0; i < cfg.maxRetries; i++ { backoff := cfg.baseBackoff if cfg.jitter { backoff = applyJitter(backoff) } backoffs = append(backoffs, backoff) } // With jitter=false, all should be 50ms for i, b := range backoffs { expected := 50 * time.Millisecond if b != expected { t.Errorf("backoff %d: expected %v, got %v", i, expected, b) } } } func TestRetryExhaustion(t *testing.T) { cfg := &retryConfig{ maxRetries: 2, baseBackoff: 10 * time.Millisecond, maxBackoff: 1 * time.Second, jitter: false, } // Verify maxRetries=2 means 3 total attempts (0, 1, 2) totalAttempts := cfg.maxRetries + 1 if totalAttempts != 3 { t.Errorf("expected 3 total attempts with maxRetries=2, got %d", totalAttempts) } } // Test that API client integrates rate limiter + circuit breaker + retry func TestClientDoIntegration(t *testing.T) { c := NewClient("test-api-key") // Verify defaults are set if c.rateLimiter == nil { t.Error("expected rate limiter to be initialized") } if c.circuitBreaker == nil { t.Error("expected circuit breaker to be initialized") } if c.retryConfig == nil { t.Error("expected retry config to be initialized") } // Verify rate limiter settings if c.rateLimiter.capacity != 10 { t.Errorf("expected rate limiter capacity 10, got %d", c.rateLimiter.capacity) } if c.retryConfig.maxRetries != 3 { t.Errorf("expected max retries 3, got %d", c.retryConfig.maxRetries) } // Test with custom options custom := NewClient("custom-key", WithRateLimiter(5, 2), // 5 TPS WithCircuitBreakerTimeout(10*time.Second), WithRetryConfig(5, 200*time.Millisecond, 10*time.Second, false)) if custom.rateLimiter.capacity != 5 { t.Errorf("expected custom rate limiter capacity 5, got %d", custom.rateLimiter.capacity) } if custom.circuitBreaker.timeout != 10*time.Second { t.Errorf("expected custom circuit breaker timeout 10s, got %v", custom.circuitBreaker.timeout) } if custom.retryConfig.maxRetries != 5 { t.Errorf("expected custom max retries 5, got %d", custom.retryConfig.maxRetries) } if custom.retryConfig.baseBackoff != 200*time.Millisecond { t.Errorf("expected custom base backoff 200ms, got %v", custom.retryConfig.baseBackoff) } } // Test building a Yandex API URL func TestBuildURL(t *testing.T) { query := map[string]string{ "from": "c146", "to": "c213", "date": "2026-08-15", } url := buildURL("/v3.0/search/", query) if url == "" { t.Error("expected non-empty URL") } if !strings.Contains(url, "from=c146") { t.Errorf("expected URL to contain from=c146, got %s", url) } if !strings.Contains(url, "to=c213") { t.Errorf("expected URL to contain to=c213, got %s", url) } if !strings.Contains(url, "date=2026-08-15") { t.Errorf("expected URL to contain date=2026-08-15, got %s", url) } } // Test API error creation func TestAPIError(t *testing.T) { err := newAPIError(404, "Not Found") if err == nil { t.Error("expected non-nil APIError") } expected := "API error 404: Not Found" if err.Error() != expected { t.Errorf("expected '%s', got '%s'", expected, err.Error()) } } // Test isRetryableError func TestIsRetryableError(t *testing.T) { // Network errors are retryable err := fmt.Errorf("connection timeout") if !isRetryableError(err) { t.Error("expected network error to be retryable") } // Nil is not retryable if isRetryableError(nil) { t.Error("expected nil error to not be retryable") } } func TestResetCircuitBreaker(t *testing.T) { cb := newCircuitBreaker() // Record failures to open the circuit cb.recordFailure() cb.recordFailure() cb.recordFailure() if cb.state != open { t.Errorf("expected state open after 3 failures, got %v", cb.state) } // Reset the circuit breaker cb.ResetCircuitBreaker() // Should be back to closed state if cb.state != closed { t.Errorf("expected state closed after reset, got %v", cb.state) } if cb.failures != 0 { t.Errorf("expected failures to be 0 after reset, got %d", cb.failures) } if cb.successes != 0 { t.Errorf("expected successes to be 0 after reset, got %d", cb.successes) } if cb.openSince != (time.Time{}) { t.Errorf("expected openSince to be zero after reset, got %v", cb.openSince) } // After reset, allow() should return true (circuit closed) for i := 0; i < 10; i++ { if !cb.allow() { t.Fatalf("expected allow() to return true after reset, attempt %d", i) } } } // Test Response parsing func TestResponseParsing(t *testing.T) { // Test with a valid JSON response jsonData := `{ "pagination": {"total": 5, "limit": 100, "offset": 0}, "search": {"date": "2026-08-13", "from": {"code": "c146", "type": "settlement", "title": "Simferopol"}, "to": {"code": "c213", "type": "settlement", "title": "Moscow"}}, "interval_segments": [], "segments": [] }` var resp Response if err := json.Unmarshal([]byte(jsonData), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Pagination.Total != 5 { t.Errorf("expected total 5, got %d", resp.Pagination.Total) } expectedFrom := "Simferopol" if resp.Search.From.Title != expectedFrom { t.Errorf("expected from title %s, got %s", expectedFrom, resp.Search.From.Title) } expectedTo := "Moscow" if resp.Search.To.Title != expectedTo { t.Errorf("expected to title %s, got %s", expectedTo, resp.Search.To.Title) } } // Test Segment parsing func TestSegmentParsing(t *testing.T) { jsonData := `{ "departure": "2026-08-13T08:00:00+03:00", "arrival": "2026-08-13T14:00:00+03:00", "duration": 21600, "has_transfers": false, "from": {"code": "s9600213", "title": "Шереметьево", "transport_type": "plane"}, "to": {"code": "s9600396", "title": "Симферополь", "transport_type": "plane"} }` var seg Segment if err := json.Unmarshal([]byte(jsonData), &seg); err != nil { t.Fatalf("failed to unmarshal segment: %v", err) } if seg.Duration != 21600 { t.Errorf("expected duration 21600, got %d", seg.Duration) } if seg.HasTransfers != false { t.Errorf("expected has_transfers false, got %v", seg.HasTransfers) } expectedFrom := "Шереметьево" if seg.From.Title != expectedFrom { t.Errorf("expected from title %s, got %s", expectedFrom, seg.From.Title) } expectedTo := "Симферополь" if seg.To.Title != expectedTo { t.Errorf("expected to title %s, got %s", expectedTo, seg.To.Title) } }