Added tests and readme
This commit is contained in:
@@ -99,17 +99,6 @@ func (a *Aggregator) Submit(nodeID string, r Result) {
|
||||
a.evaluate(r.CheckID)
|
||||
}
|
||||
|
||||
// SnapshotAll returns the current aggregate view of every known check.
|
||||
func (a *Aggregator) SnapshotAll() map[string]Snapshot {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
out := make(map[string]Snapshot, len(a.perCheck))
|
||||
for id, st := range a.perCheck {
|
||||
out[id] = a.snapshotLocked(id, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SnapshotFor returns the aggregate for a single check.
|
||||
func (a *Aggregator) SnapshotFor(checkID string) (Snapshot, bool) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jasper/quptime/internal/config"
|
||||
)
|
||||
|
||||
func TestAggregatorHysteresisRequiresConsecutiveEvals(t *testing.T) {
|
||||
cluster := &config.ClusterConfig{Checks: []config.Check{
|
||||
{ID: "c1", Name: "x", Interval: 10 * time.Second},
|
||||
}}
|
||||
|
||||
var transitions atomic.Int32
|
||||
agg := NewAggregator(cluster, func(_ *config.Check, _, _ State, _ Snapshot) {
|
||||
transitions.Add(1)
|
||||
})
|
||||
|
||||
// First OK submission — candidate=Up, committed still Unknown.
|
||||
agg.Submit("nodeA", Result{CheckID: "c1", OK: true, Timestamp: time.Now()})
|
||||
snap, _ := agg.SnapshotFor("c1")
|
||||
if snap.State != StateUnknown {
|
||||
t.Errorf("after one tick state=%s want unknown", snap.State)
|
||||
}
|
||||
if transitions.Load() != 0 {
|
||||
t.Errorf("transitions=%d after one tick, want 0", transitions.Load())
|
||||
}
|
||||
|
||||
// Second OK — hysteresis satisfied, commit Up.
|
||||
agg.Submit("nodeA", Result{CheckID: "c1", OK: true, Timestamp: time.Now()})
|
||||
snap, _ = agg.SnapshotFor("c1")
|
||||
if snap.State != StateUp {
|
||||
t.Errorf("after two ticks state=%s want up", snap.State)
|
||||
}
|
||||
if transitions.Load() != 1 {
|
||||
t.Errorf("transitions=%d after commit, want 1", transitions.Load())
|
||||
}
|
||||
|
||||
// Single failure — candidate flips to Down, committed stays Up.
|
||||
agg.Submit("nodeA", Result{CheckID: "c1", OK: false, Detail: "boom", Timestamp: time.Now()})
|
||||
snap, _ = agg.SnapshotFor("c1")
|
||||
if snap.State != StateUp {
|
||||
t.Errorf("single fail flipped state prematurely: %s", snap.State)
|
||||
}
|
||||
|
||||
// Second failure — commit Down.
|
||||
agg.Submit("nodeA", Result{CheckID: "c1", OK: false, Detail: "boom", Timestamp: time.Now()})
|
||||
snap, _ = agg.SnapshotFor("c1")
|
||||
if snap.State != StateDown {
|
||||
t.Errorf("after two fails state=%s want down", snap.State)
|
||||
}
|
||||
if transitions.Load() != 2 {
|
||||
t.Errorf("transitions=%d after second commit, want 2", transitions.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregatorMajorityRule(t *testing.T) {
|
||||
cluster := &config.ClusterConfig{Checks: []config.Check{
|
||||
{ID: "c1", Name: "x", Interval: 10 * time.Second},
|
||||
}}
|
||||
agg := NewAggregator(cluster, nil)
|
||||
|
||||
// 2 OK + 1 fail → candidate Up.
|
||||
now := time.Now()
|
||||
agg.Submit("a", Result{CheckID: "c1", OK: true, Timestamp: now})
|
||||
agg.Submit("b", Result{CheckID: "c1", OK: true, Timestamp: now})
|
||||
agg.Submit("c", Result{CheckID: "c1", OK: false, Timestamp: now})
|
||||
|
||||
snap, _ := agg.SnapshotFor("c1")
|
||||
if snap.OKCount != 2 || snap.NotOK != 1 {
|
||||
t.Errorf("counts wrong: %+v", snap)
|
||||
}
|
||||
|
||||
// flip the majority
|
||||
for i := 0; i < 2; i++ {
|
||||
agg.Submit("a", Result{CheckID: "c1", OK: false, Timestamp: time.Now()})
|
||||
agg.Submit("b", Result{CheckID: "c1", OK: false, Timestamp: time.Now()})
|
||||
agg.Submit("c", Result{CheckID: "c1", OK: false, Timestamp: time.Now()})
|
||||
}
|
||||
snap, _ = agg.SnapshotFor("c1")
|
||||
if snap.State != StateDown {
|
||||
t.Errorf("majority-fail did not transition to down: %s", snap.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregatorDropsUnknownChecks(t *testing.T) {
|
||||
cluster := &config.ClusterConfig{}
|
||||
agg := NewAggregator(cluster, nil)
|
||||
|
||||
agg.Submit("a", Result{CheckID: "ghost", OK: true, Timestamp: time.Now()})
|
||||
if _, ok := agg.SnapshotFor("ghost"); ok {
|
||||
t.Error("aggregator kept state for unconfigured check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregatorIgnoresStaleResults(t *testing.T) {
|
||||
cluster := &config.ClusterConfig{Checks: []config.Check{
|
||||
{ID: "c1", Name: "x", Interval: 10 * time.Second},
|
||||
}}
|
||||
agg := NewAggregator(cluster, nil)
|
||||
|
||||
old := time.Now().Add(-10 * time.Minute)
|
||||
agg.Submit("a", Result{CheckID: "c1", OK: true, Timestamp: old})
|
||||
|
||||
snap, _ := agg.SnapshotFor("c1")
|
||||
if snap.Reports != 0 {
|
||||
t.Errorf("stale report counted: %+v", snap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jasper/quptime/internal/config"
|
||||
)
|
||||
|
||||
func TestHTTPProberHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("hello world"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckHTTP, Target: srv.URL,
|
||||
Timeout: 5 * time.Second, ExpectStatus: 200,
|
||||
})
|
||||
if !res.OK {
|
||||
t.Errorf("expected OK, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProberBodyMatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("the magic word is xyzzy and other stuff"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hit := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckHTTP, Target: srv.URL,
|
||||
Timeout: 5 * time.Second, BodyMatch: "xyzzy",
|
||||
})
|
||||
if !hit.OK {
|
||||
t.Errorf("expected match, got %+v", hit)
|
||||
}
|
||||
|
||||
miss := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckHTTP, Target: srv.URL,
|
||||
Timeout: 5 * time.Second, BodyMatch: "absent",
|
||||
})
|
||||
if miss.OK {
|
||||
t.Errorf("expected miss, got %+v", miss)
|
||||
}
|
||||
if !strings.Contains(miss.Detail, "body match") {
|
||||
t.Errorf("detail unexpected: %q", miss.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProberStatusMismatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckHTTP, Target: srv.URL, Timeout: 5 * time.Second,
|
||||
})
|
||||
if res.OK {
|
||||
t.Errorf("500 should fail check, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPProberHappyPath(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
res := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckTCP, Target: ln.Addr().String(),
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if !res.OK {
|
||||
t.Errorf("expected OK, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPProberRefusedConnection(t *testing.T) {
|
||||
// Listen and immediately close so the address is known-bad.
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
res := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: config.CheckTCP, Target: addr, Timeout: 1 * time.Second,
|
||||
})
|
||||
if res.OK {
|
||||
t.Errorf("dead address should fail check, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownCheckType(t *testing.T) {
|
||||
res := Run(context.Background(), &config.Check{
|
||||
ID: "c", Type: "bogus", Target: "x",
|
||||
})
|
||||
if res.OK {
|
||||
t.Error("unknown check type should not succeed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user