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.
110 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|