package push import ( "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCircuitBreaker_StartsInClosedState(t *testing.T) { cb := NewCircuitBreaker("test") assert.Equal(t, "closed", cb.State()) assert.True(t, cb.Allow()) } func TestCircuitBreaker_OpensAfterThresholdFailures(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(3)) // First two failures should keep it closed cb.RecordFailure() assert.Equal(t, "closed", cb.State()) assert.True(t, cb.Allow()) cb.RecordFailure() assert.Equal(t, "closed", cb.State()) assert.True(t, cb.Allow()) // Third failure should open it cb.RecordFailure() assert.Equal(t, "open", cb.State()) assert.False(t, cb.Allow()) } func TestCircuitBreaker_DefaultThresholdIsFive(t *testing.T) { cb := NewCircuitBreaker("test") for i := 0; i < 4; i++ { cb.RecordFailure() assert.Equal(t, "closed", cb.State()) } cb.RecordFailure() // 5th failure assert.Equal(t, "open", cb.State()) } func TestCircuitBreaker_RejectsRequestsWhenOpen(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(1)) cb.RecordFailure() assert.Equal(t, "open", cb.State()) // Multiple calls should all be rejected for i := 0; i < 10; i++ { assert.False(t, cb.Allow()) } } func TestCircuitBreaker_TransitionsToHalfOpenAfterRecoveryTimeout(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(1), WithRecoveryTimeout(50*time.Millisecond), ) cb.RecordFailure() assert.Equal(t, "open", cb.State()) assert.False(t, cb.Allow()) // Wait for recovery timeout time.Sleep(60 * time.Millisecond) // Should now allow one request (half-open) assert.True(t, cb.Allow()) assert.Equal(t, "half-open", cb.State()) } func TestCircuitBreaker_HalfOpenRejectsSecondRequest(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(1), WithRecoveryTimeout(50*time.Millisecond), ) cb.RecordFailure() time.Sleep(60 * time.Millisecond) // First request allowed (probe) assert.True(t, cb.Allow()) assert.Equal(t, "half-open", cb.State()) // Second request rejected while probe is in flight assert.False(t, cb.Allow()) } func TestCircuitBreaker_HalfOpenSuccess_ResetsToClosed(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(1), WithRecoveryTimeout(50*time.Millisecond), ) cb.RecordFailure() time.Sleep(60 * time.Millisecond) // Probe request assert.True(t, cb.Allow()) // Probe succeeds cb.RecordSuccess() assert.Equal(t, "closed", cb.State()) assert.Equal(t, 0, cb.Counts()) // Normal operation resumes assert.True(t, cb.Allow()) assert.True(t, cb.Allow()) } func TestCircuitBreaker_HalfOpenFailure_ReturnsToOpen(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(2), WithRecoveryTimeout(50*time.Millisecond), ) // Open the circuit cb.RecordFailure() cb.RecordFailure() assert.Equal(t, "open", cb.State()) time.Sleep(60 * time.Millisecond) // Probe request assert.True(t, cb.Allow()) assert.Equal(t, "half-open", cb.State()) // Probe fails - the failure count is now 3 which is >= threshold of 2 cb.RecordFailure() assert.Equal(t, "open", cb.State()) assert.False(t, cb.Allow()) } func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(3)) cb.RecordFailure() cb.RecordFailure() assert.Equal(t, 2, cb.Counts()) // A success should reset the counter cb.RecordSuccess() assert.Equal(t, 0, cb.Counts()) assert.Equal(t, "closed", cb.State()) // Now it should take 3 more failures to open cb.RecordFailure() cb.RecordFailure() assert.Equal(t, "closed", cb.State()) cb.RecordFailure() assert.Equal(t, "open", cb.State()) } func TestCircuitBreaker_Reset(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(1)) cb.RecordFailure() assert.Equal(t, "open", cb.State()) cb.Reset() assert.Equal(t, "closed", cb.State()) assert.Equal(t, 0, cb.Counts()) assert.True(t, cb.Allow()) } func TestCircuitBreaker_Name(t *testing.T) { cb := NewCircuitBreaker("apns-breaker") assert.Equal(t, "apns-breaker", cb.Name()) } func TestCircuitBreaker_CustomOptions(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(10), WithRecoveryTimeout(5*time.Minute), ) // Should take 10 failures to open for i := 0; i < 9; i++ { cb.RecordFailure() assert.Equal(t, "closed", cb.State()) } cb.RecordFailure() assert.Equal(t, "open", cb.State()) } func TestCircuitBreaker_InvalidOptionsIgnored(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(0), // Should be ignored (keeps default) WithRecoveryTimeout(-1), // Should be ignored (keeps default) ) // Default threshold of 5 should still apply for i := 0; i < 4; i++ { cb.RecordFailure() assert.Equal(t, "closed", cb.State()) } cb.RecordFailure() assert.Equal(t, "open", cb.State()) } func TestCircuitBreaker_ThreadSafety(t *testing.T) { cb := NewCircuitBreaker("test", WithFailureThreshold(100), WithRecoveryTimeout(10*time.Millisecond), ) var wg sync.WaitGroup const goroutines = 50 const iterations = 100 // Hammer it from many goroutines for i := 0; i < goroutines; i++ { wg.Add(1) go func(id int) { defer wg.Done() for j := 0; j < iterations; j++ { cb.Allow() if j%2 == 0 { cb.RecordFailure() } else { cb.RecordSuccess() } _ = cb.State() _ = cb.Counts() } }(i) } wg.Wait() // Should not panic or deadlock. State should be valid. state := cb.State() require.Contains(t, []string{"closed", "open", "half-open"}, state) } func TestCircuitBreaker_FullLifecycle(t *testing.T) { cb := NewCircuitBreaker("lifecycle-test", WithFailureThreshold(3), WithRecoveryTimeout(50*time.Millisecond), ) // 1. Closed: requests flow normally assert.True(t, cb.Allow()) cb.RecordSuccess() assert.Equal(t, "closed", cb.State()) // 2. Accumulate failures cb.RecordFailure() cb.RecordFailure() assert.Equal(t, "closed", cb.State()) // 3. Third failure opens the circuit cb.RecordFailure() assert.Equal(t, "open", cb.State()) assert.False(t, cb.Allow()) // 4. Wait for recovery time.Sleep(60 * time.Millisecond) // 5. Half-open: probe request allowed assert.True(t, cb.Allow()) assert.Equal(t, "half-open", cb.State()) // 6. Probe succeeds, back to closed cb.RecordSuccess() assert.Equal(t, "closed", cb.State()) assert.True(t, cb.Allow()) }