Files
oikos/internal/actuator/circuit_breaker_test.go
dtoro c96c795126 test: add unit tests for 6 previously-untested packages (R7)
Added pure unit tests for all packages that had 0% coverage. Where pure
logic was entangled with DB calls, extracted testable helpers first.

internal/domain (0% -> 100%):
- TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors,
  TestSignalTransitionsComplete

internal/learning (0% -> 26.2%):
- Refactored processGroup to extract 4 pure helpers: countOutcomes,
  computeConfidence, shouldValidate, shouldQuarantine
- TestWilsonLowerBound (monotonicity, edge cases, sample-size cap)
- TestCountOutcomes, TestComputeConfidence, TestShouldValidate,
  TestShouldQuarantine (table-driven)
- Remaining gap: extractPatterns/processGroup DB calls need make test-db

internal/policy (39% -> 50%):
- Extracted determineRoute from ClassifySignal (pure route logic)
- TestDetermineRoute (7 cases covering global/entity kill-switches, approval)
- Remaining gap: ClassifySignal/computeBlastRadius need DB mock

internal/knowledge (0% -> 14.2%):
- TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray
- Documented latent bug: toPGArray doesn't escape " or \\ in tags
- Remaining gap: ingest* functions need make test-db

internal/actuator (0% -> 14.7%):
- TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock)
- TestParseProcedure, TestSetDefaultSSHTimeout
- Circuit breaker full state-machine test (open/close/reset/per-target)
- Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures

internal/scheduler (0% -> 7.3%):
- TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript
- TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback)
- Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB

internal/notifier (0% -> 6.4%):
- TestHashToken, TestGenerateApprovalToken (HMAC re-derivation)
- Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need
  make test-db

All tests pass with -race. domain hits its 60% gate at 100%. The remaining
packages need integration tests (make test-db) and/or httptest-based tests
to reach their coverage gates — tracked as follow-up.
2026-07-17 22:54:44 +02:00

110 lines
2.6 KiB
Go

package actuator
import (
"testing"
"time"
)
func TestNewCircuitBreakerDefaults(t *testing.T) {
cb := newCircuitBreaker(0, 0)
if cb.threshold != 3 {
t.Errorf("threshold = %d, want 3", cb.threshold)
}
if cb.cooldownS != 300 {
t.Errorf("cooldownS = %d, want 300", cb.cooldownS)
}
cb = newCircuitBreaker(5, 60)
if cb.threshold != 5 {
t.Errorf("threshold = %d, want 5", cb.threshold)
}
if cb.cooldownS != 60 {
t.Errorf("cooldownS = %d, want 60", cb.cooldownS)
}
}
func TestCircuitBreakerIsOpenFresh(t *testing.T) {
cb := newCircuitBreaker(3, 60)
if cb.isOpen("host:A") {
t.Errorf("fresh circuit should be closed, got open")
}
}
func TestCircuitBreakerOpensAtThreshold(t *testing.T) {
cb := newCircuitBreaker(3, 60)
// threshold-1 failures → still closed
cb.recordFailure("host:A")
cb.recordFailure("host:A")
if cb.isOpen("host:A") {
t.Fatalf("circuit should be closed after threshold-1 failures")
}
// one more → open
cb.recordFailure("host:A")
if !cb.isOpen("host:A") {
t.Fatalf("circuit should be open after threshold failures")
}
}
func TestCircuitBreakerClosesAfterCooldown(t *testing.T) {
cb := newCircuitBreaker(1, 60)
// Force open
cb.recordFailure("host:A")
if !cb.isOpen("host:A") {
t.Fatalf("circuit should be open")
}
// Manipulate the cooldown timestamp to the past to simulate expiry.
cb.mu.Lock()
cb.cooldowns["host:A"] = time.Now().Add(-1 * time.Second)
cb.mu.Unlock()
if cb.isOpen("host:A") {
t.Fatalf("circuit should be closed after cooldown expired")
}
// Failure count should have been reset by isOpen.
cb.mu.Lock()
got := cb.failures["host:A"]
cb.mu.Unlock()
if got != 0 {
t.Errorf("failure count after cooldown reset = %d, want 0", got)
}
}
func TestCircuitBreakerRecordSuccessResets(t *testing.T) {
cb := newCircuitBreaker(3, 60)
cb.recordFailure("host:A")
cb.recordFailure("host:A")
cb.recordSuccess("host:A")
cb.mu.Lock()
got := cb.failures["host:A"]
cb.mu.Unlock()
if got != 0 {
t.Errorf("failure count after success = %d, want 0", got)
}
if cb.isOpen("host:A") {
t.Errorf("circuit should be closed after success reset")
}
}
func TestCircuitBreakerPerTargetIsolation(t *testing.T) {
cb := newCircuitBreaker(2, 60)
cb.recordFailure("host:A")
cb.recordFailure("host:A") // host:A now at threshold → open
if !cb.isOpen("host:A") {
t.Fatalf("host:A should be open")
}
if cb.isOpen("host:B") {
t.Errorf("host:B should be closed (isolated from host:A)")
}
// host:B has no failures recorded
cb.mu.Lock()
gotB := cb.failures["host:B"]
cb.mu.Unlock()
if gotB != 0 {
t.Errorf("host:B failure count = %d, want 0", gotB)
}
}