Added tests and readme

This commit is contained in:
2026-05-12 06:20:51 +00:00
parent 7e85bb0fcc
commit 139c224a31
23 changed files with 1449 additions and 97 deletions
-11
View File
@@ -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()
+111
View File
@@ -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)
}
}
+118
View File
@@ -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")
}
}