Files
trip-planner/internal/yandex/client_test.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)
}
}