Pulse/internal/ai/circuit/breaker_additional_test.go
2026-01-25 21:08:44 +00:00

344 lines
8.6 KiB
Go

package circuit
import (
"errors"
"testing"
"time"
)
func TestStateString_Unknown(t *testing.T) {
if got := State(99).String(); got != "unknown" {
t.Fatalf("expected unknown, got %s", got)
}
}
func TestNewBreaker_DefaultsApplied(t *testing.T) {
b := NewBreaker("defaults", Config{})
if b.config.FailureThreshold != 3 {
t.Fatalf("expected default FailureThreshold, got %d", b.config.FailureThreshold)
}
if b.config.SuccessThreshold != 2 {
t.Fatalf("expected default SuccessThreshold, got %d", b.config.SuccessThreshold)
}
if b.config.InitialBackoff != time.Second {
t.Fatalf("expected default InitialBackoff, got %s", b.config.InitialBackoff)
}
if b.config.MaxBackoff != 5*time.Minute {
t.Fatalf("expected default MaxBackoff, got %s", b.config.MaxBackoff)
}
if b.config.BackoffMultiplier != 2.0 {
t.Fatalf("expected default BackoffMultiplier, got %.1f", b.config.BackoffMultiplier)
}
if b.config.HalfOpenTimeout != 30*time.Second {
t.Fatalf("expected default HalfOpenTimeout, got %s", b.config.HalfOpenTimeout)
}
}
func TestBreaker_CanAllow_DoesNotTransition(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
b.mu.Lock()
b.state = StateOpen
b.currentBackoff = time.Hour
b.openedAt = time.Now().Add(-2 * time.Hour)
b.mu.Unlock()
if !b.CanAllow() {
t.Fatalf("expected CanAllow to return true after backoff")
}
if b.State() != StateOpen {
t.Fatalf("expected state to remain open on CanAllow")
}
}
func TestBreaker_RecordFailure_InvalidDoesNotTrip(t *testing.T) {
cfg := DefaultConfig()
cfg.FailureThreshold = 1
b := NewBreaker("test", cfg)
b.RecordFailureWithCategory(errors.New("bad request"), ErrorCategoryInvalid)
if b.State() != StateClosed {
t.Fatalf("expected invalid error not to trip circuit")
}
}
func TestBreaker_RecordFailure_RateLimitTrips(t *testing.T) {
cfg := DefaultConfig()
cfg.FailureThreshold = 5
b := NewBreaker("test", cfg)
b.RecordFailureWithCategory(errors.New("rate limit"), ErrorCategoryRateLimit)
if b.State() != StateOpen {
t.Fatalf("expected rate limit error to trip circuit")
}
}
func TestBreaker_RecordFailure_HalfOpenBackoffCaps(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxBackoff = 100 * time.Millisecond
cfg.BackoffMultiplier = 2.0
b := NewBreaker("test", cfg)
b.mu.Lock()
b.state = StateHalfOpen
b.currentBackoff = 80 * time.Millisecond
b.mu.Unlock()
b.RecordFailureWithCategory(errors.New("fail"), ErrorCategoryTransient)
if b.State() != StateOpen {
t.Fatalf("expected state to be open after half-open failure")
}
if b.currentBackoff != cfg.MaxBackoff {
t.Fatalf("expected backoff to cap at max, got %s", b.currentBackoff)
}
}
func TestBreaker_Callbacks(t *testing.T) {
cfg := DefaultConfig()
cfg.FailureThreshold = 1
b := NewBreaker("test", cfg)
stateCh := make(chan State, 1)
tripCh := make(chan error, 1)
b.SetOnStateChange(func(_, to State) {
stateCh <- to
})
b.SetOnTrip(func(err error) {
tripCh <- err
})
testErr := errors.New("boom")
b.RecordFailure(testErr)
select {
case state := <-stateCh:
if state != StateOpen {
t.Fatalf("expected state change to open, got %s", state.String())
}
case <-time.After(200 * time.Millisecond):
t.Fatalf("expected state change callback to fire")
}
select {
case err := <-tripCh:
if err == nil || err.Error() != testErr.Error() {
t.Fatalf("expected trip callback with error")
}
case <-time.After(200 * time.Millisecond):
t.Fatalf("expected trip callback to fire")
}
}
func TestBreaker_GetStatus_TimeUntilRetry(t *testing.T) {
cfg := DefaultConfig()
cfg.FailureThreshold = 1
cfg.InitialBackoff = 50 * time.Millisecond
b := NewBreaker("test", cfg)
b.RecordFailure(errors.New("fail"))
status := b.GetStatus()
if status.State != "open" {
t.Fatalf("expected status open, got %s", status.State)
}
if status.LastError == "" {
t.Fatalf("expected last error to be set")
}
if status.TimeUntilRetry <= 0 {
t.Fatalf("expected time until retry to be set")
}
}
func TestBreaker_ExecuteWithCategory_InvalidDoesNotTrip(t *testing.T) {
cfg := DefaultConfig()
cfg.FailureThreshold = 1
b := NewBreaker("test", cfg)
err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
return errors.New("invalid"), ErrorCategoryInvalid
})
if err == nil {
t.Fatalf("expected error")
}
if b.State() != StateClosed {
t.Fatalf("expected state to remain closed")
}
}
func TestIsCircuitOpen_Additional(t *testing.T) {
if !IsCircuitOpen(ErrCircuitOpen) {
t.Fatalf("expected ErrCircuitOpen to be recognized")
}
if IsCircuitOpen(errors.New("other")) {
t.Fatalf("expected non-circuit error to be false")
}
}
func TestCategorizeError_Additional(t *testing.T) {
tests := []struct {
err error
expected ErrorCategory
}{
{errors.New("rate limit exceeded"), ErrorCategoryRateLimit},
{errors.New("429 too many requests"), ErrorCategoryRateLimit},
{errors.New("400 bad request"), ErrorCategoryInvalid},
{errors.New("unauthorized api key"), ErrorCategoryFatal},
{errors.New("payment required"), ErrorCategoryFatal},
{errors.New("random failure"), ErrorCategoryTransient},
{nil, ErrorCategoryTransient},
}
for _, tt := range tests {
if got := CategorizeError(tt.err); got != tt.expected {
t.Fatalf("expected %v, got %v for %v", tt.expected, got, tt.err)
}
}
}
func TestBreaker_IsOpenClosed(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
if !b.IsClosed() || b.IsOpen() {
t.Fatalf("expected breaker to start closed")
}
b.mu.Lock()
b.state = StateOpen
b.mu.Unlock()
if !b.IsOpen() || b.IsClosed() {
t.Fatalf("expected breaker to report open")
}
}
func TestCircuitOpenErrorMessage(t *testing.T) {
err := circuitOpenError{}
if err.Error() != "circuit breaker is open" {
t.Fatalf("unexpected error message: %s", err.Error())
}
}
func TestBreaker_CanAllow_Branches(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
if !b.CanAllow() {
t.Fatalf("expected CanAllow true for closed")
}
b.mu.Lock()
b.state = StateOpen
b.openedAt = time.Now()
b.currentBackoff = time.Hour
b.mu.Unlock()
if b.CanAllow() {
t.Fatalf("expected CanAllow false before backoff elapses")
}
b.mu.Lock()
b.openedAt = time.Now().Add(-2 * time.Hour)
b.mu.Unlock()
if !b.CanAllow() {
t.Fatalf("expected CanAllow true after backoff")
}
b.mu.Lock()
b.state = StateHalfOpen
b.mu.Unlock()
if !b.CanAllow() {
t.Fatalf("expected CanAllow true for half-open")
}
}
func TestBreaker_ExecuteWithCategory_SuccessAndOpen(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
if err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
return nil, ErrorCategoryTransient
}); err != nil {
t.Fatalf("expected success, got %v", err)
}
cfg := DefaultConfig()
cfg.FailureThreshold = 1
cfg.InitialBackoff = time.Hour
b = NewBreaker("test", cfg)
b.RecordFailure(errors.New("fail"))
if err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
return nil, ErrorCategoryTransient
}); err != ErrCircuitOpen {
t.Fatalf("expected ErrCircuitOpen, got %v", err)
}
}
func TestStateString_All(t *testing.T) {
cases := map[State]string{
StateClosed: "closed",
StateOpen: "open",
StateHalfOpen: "half-open",
}
for state, expected := range cases {
if state.String() != expected {
t.Fatalf("expected %s for state %d", expected, state)
}
}
}
func TestBreaker_Allow_TransitionsOpenToHalfOpen(t *testing.T) {
cfg := DefaultConfig()
cfg.InitialBackoff = 10 * time.Millisecond
b := NewBreaker("test", cfg)
b.mu.Lock()
b.state = StateOpen
b.openedAt = time.Now().Add(-time.Second)
b.currentBackoff = 10 * time.Millisecond
b.mu.Unlock()
if !b.Allow() {
t.Fatalf("expected Allow to return true after backoff")
}
if b.State() != StateHalfOpen {
t.Fatalf("expected state to transition to half-open")
}
}
func TestBreaker_TransitionTo_NoOp(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
b.transitionTo(StateClosed)
if b.State() != StateClosed {
t.Fatalf("expected state to remain closed")
}
}
func TestBreaker_Allow_BlocksBeforeBackoff(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
b.mu.Lock()
b.state = StateOpen
b.openedAt = time.Now()
b.currentBackoff = time.Hour
b.mu.Unlock()
if b.Allow() {
t.Fatalf("expected Allow to return false before backoff elapses")
}
if b.State() != StateOpen {
t.Fatalf("expected state to remain open")
}
}
func TestBreaker_Allow_HalfOpen(t *testing.T) {
b := NewBreaker("test", DefaultConfig())
b.mu.Lock()
b.state = StateHalfOpen
b.mu.Unlock()
if !b.Allow() {
t.Fatalf("expected Allow true in half-open")
}
}
func TestToLower(t *testing.T) {
if toLower("AbC123") != "abc123" {
t.Fatalf("expected toLower to normalize casing")
}
if toLower("lower") != "lower" {
t.Fatalf("expected lowercase input to remain unchanged")
}
}