402 lines
11 KiB
Go
402 lines
11 KiB
Go
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)
|
|
}
|
|
}
|