gomcp/internal/domain/circuitbreaker/breaker_test.go

184 lines
4.5 KiB
Go

// Copyright 2026 Syntrex Lab. All rights reserved.
// Use of this source code is governed by an Apache-2.0 license
// that can be found in the LICENSE file.
package circuitbreaker
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_DefaultState(t *testing.T) {
b := New(nil)
assert.Equal(t, StateHealthy, b.CurrentState())
assert.True(t, b.IsAllowed())
}
func TestBreaker_HealthyToDegraded(t *testing.T) {
b := New(&Config{DegradeThreshold: 3})
// 2 anomalies: still healthy.
b.RecordAnomaly("test1")
b.RecordAnomaly("test2")
assert.Equal(t, StateHealthy, b.CurrentState())
// 3rd anomaly: degrade.
b.RecordAnomaly("test3")
assert.Equal(t, StateDegraded, b.CurrentState())
assert.True(t, b.IsAllowed(), "degraded still allows pipeline")
}
func TestBreaker_DegradedToOpen(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, OpenThreshold: 3})
// Trigger degraded.
halted := b.RecordAnomaly("trigger degrade")
assert.False(t, halted)
assert.Equal(t, StateDegraded, b.CurrentState())
// More anomalies until open.
b.RecordAnomaly("a2")
halted = b.RecordAnomaly("a3")
assert.True(t, halted)
assert.Equal(t, StateOpen, b.CurrentState())
assert.False(t, b.IsAllowed())
}
func TestBreaker_DegradedRecovery(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, RecoveryThreshold: 2})
b.RecordAnomaly("trigger")
assert.Equal(t, StateDegraded, b.CurrentState())
// 1 clean: not enough.
b.RecordClean()
assert.Equal(t, StateDegraded, b.CurrentState())
// 2 clean: recovery.
b.RecordClean()
assert.Equal(t, StateHealthy, b.CurrentState())
}
func TestBreaker_RecoveryResetByAnomaly(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, RecoveryThreshold: 3})
b.RecordAnomaly("trigger")
b.RecordClean()
b.RecordClean()
// Anomaly resets consecutive clean count.
b.RecordAnomaly("reset")
b.RecordClean()
assert.Equal(t, StateDegraded, b.CurrentState(), "recovery should be reset")
}
func TestBreaker_ManualReset(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, OpenThreshold: 2})
b.RecordAnomaly("a1")
b.RecordAnomaly("a2")
assert.Equal(t, StateOpen, b.CurrentState())
b.Reset("external watchdog")
assert.Equal(t, StateHealthy, b.CurrentState())
assert.True(t, b.IsAllowed())
}
func TestBreaker_WatchdogAutoReset(t *testing.T) {
b := New(&Config{
DegradeThreshold: 1,
OpenThreshold: 2,
WatchdogTimeout: 10 * time.Millisecond,
})
b.RecordAnomaly("a1")
b.RecordAnomaly("a2")
assert.Equal(t, StateOpen, b.CurrentState())
// Wait for watchdog.
time.Sleep(15 * time.Millisecond)
// RecordClean triggers watchdog check.
state := b.RecordClean()
assert.Equal(t, StateHealthy, state)
}
func TestBreaker_DegradedReducesIterations(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, DegradedMaxIterations: 2})
assert.Equal(t, 5, b.MaxIterations(5), "healthy: full iterations")
b.RecordAnomaly("trigger")
assert.Equal(t, 2, b.MaxIterations(5), "degraded: reduced iterations")
}
func TestBreaker_Events(t *testing.T) {
b := New(&Config{DegradeThreshold: 1, OpenThreshold: 2})
b.RecordAnomaly("a1")
b.RecordAnomaly("a2")
b.Reset("test")
events := b.Events()
require.Len(t, events, 3) // HEALTHY→DEGRADED, DEGRADED→OPEN, OPEN→HEALTHY
assert.Equal(t, StateHealthy, events[0].From)
assert.Equal(t, StateDegraded, events[0].To)
assert.Equal(t, StateDegraded, events[1].From)
assert.Equal(t, StateOpen, events[1].To)
assert.Equal(t, StateOpen, events[2].From)
assert.Equal(t, StateHealthy, events[2].To)
}
func TestBreaker_GetStatus(t *testing.T) {
b := New(nil)
b.RecordAnomaly("test")
s := b.GetStatus()
assert.Equal(t, "HEALTHY", s.State)
assert.Equal(t, 1, s.AnomalyCount)
assert.Equal(t, 1, s.TotalAnomalies)
assert.GreaterOrEqual(t, s.UptimeSeconds, 0.0)
}
func TestBreaker_StateString(t *testing.T) {
assert.Equal(t, "HEALTHY", StateHealthy.String())
assert.Equal(t, "DEGRADED", StateDegraded.String())
assert.Equal(t, "OPEN", StateOpen.String())
assert.Equal(t, "UNKNOWN", State(99).String())
}
func TestBreaker_ConcurrentSafety(t *testing.T) {
b := New(&Config{DegradeThreshold: 100})
done := make(chan struct{})
go func() {
for i := 0; i < 50; i++ {
b.RecordAnomaly("concurrent")
}
done <- struct{}{}
}()
go func() {
for i := 0; i < 50; i++ {
b.RecordClean()
}
done <- struct{}{}
}()
go func() {
for i := 0; i < 50; i++ {
_ = b.GetStatus()
_ = b.IsAllowed()
_ = b.CurrentState()
}
done <- struct{}{}
}()
<-done
<-done
<-done
// No race condition panic = pass.
}