Files
base-ha/ha_endpoints_test.go
zeekay 0395b27365 feat(ha): /_ha/leader + /_ha/peers endpoints + X-Base-Leader header
Exposes Quasar writer-pin state for leader-aware upstream consumers:

- GET /_ha/leader  → {leader_url, node_id, term, lease_expires}
- GET /_ha/peers   → member list with up/down + ordinal
- X-Base-Leader response header on all responses (passive tracking)

Gateway's base_ha upstream polls /_ha/leader every 1s and pins
POST/PUT/PATCH/DELETE to the current writer — client never sees 307.

Files:
  ha_endpoints.go, ha_endpoints_test.go (new)
  quasar.go +80/-25  (LeaderInfo, Peers, term monotonicity)
  quasar_test.go (new — failover + peers sort-order)
  main.go +22 (route registration + header middleware)
2026-04-22 15:01:22 -07:00

160 lines
4.5 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// fakeProvider is a deterministic HAEndpointProvider for unit tests.
type fakeProvider struct {
url string
nodeID string
term uint64
lease time.Time
peers []PeerResponse
}
func (f *fakeProvider) LeaderInfo() (string, string, uint64, time.Time) {
return f.url, f.nodeID, f.term, f.lease
}
func (f *fakeProvider) Peers() []PeerResponse { return f.peers }
func TestLeaderHandlerReturnsCurrentWriter(t *testing.T) {
t0 := time.Date(2026, 4, 22, 19, 30, 0, 0, time.UTC)
p := &fakeProvider{
url: "http://foo-1.foo-hs.hanzo.svc.cluster.local:8090",
nodeID: "foo-1",
term: 42,
lease: t0,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/_ha/leader", nil)
HandleLeader(p)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rr.Code)
}
if got := rr.Header().Get("X-Base-Leader"); got != p.url {
t.Fatalf("X-Base-Leader: want %q, got %q", p.url, got)
}
if got := rr.Header().Get("X-Base-Term"); got != "42" {
t.Fatalf("X-Base-Term: want 42, got %q", got)
}
var body LeaderResponse
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.LeaderURL != p.url || body.NodeID != p.nodeID || body.Term != p.term {
t.Fatalf("body mismatch: %+v", body)
}
if !body.LeaseExpires.Equal(t0) {
t.Fatalf("lease expires: want %v, got %v", t0, body.LeaseExpires)
}
}
func TestLeaderHandlerFailover(t *testing.T) {
p := &fakeProvider{
url: "http://foo-0.foo-hs.hanzo.svc.cluster.local:8090",
nodeID: "foo-0",
term: 1,
lease: time.Now().Add(2 * time.Second),
}
rr := httptest.NewRecorder()
HandleLeader(p)(rr, httptest.NewRequest(http.MethodGet, "/_ha/leader", nil))
var first LeaderResponse
_ = json.NewDecoder(rr.Body).Decode(&first)
if first.NodeID != "foo-0" || first.Term != 1 {
t.Fatalf("pre-failover: %+v", first)
}
// Simulate failover: writer changes + term bumps.
p.url = "http://foo-1.foo-hs.hanzo.svc.cluster.local:8090"
p.nodeID = "foo-1"
p.term = 2
p.lease = time.Now().Add(2 * time.Second)
rr = httptest.NewRecorder()
HandleLeader(p)(rr, httptest.NewRequest(http.MethodGet, "/_ha/leader", nil))
var after LeaderResponse
_ = json.NewDecoder(rr.Body).Decode(&after)
if after.NodeID != "foo-1" {
t.Fatalf("post-failover node_id: want foo-1, got %q", after.NodeID)
}
if after.Term <= first.Term {
t.Fatalf("term must be monotonic: %d -> %d", first.Term, after.Term)
}
}
func TestLeaderHandlerRejectsNonGet(t *testing.T) {
p := &fakeProvider{url: "http://x:1", nodeID: "x", term: 1}
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
rr := httptest.NewRecorder()
HandleLeader(p)(rr, httptest.NewRequest(m, "/_ha/leader", nil))
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("%s: want 405, got %d", m, rr.Code)
}
}
}
func TestPeersHandlerReturnsSortedByOrdinal(t *testing.T) {
p := &fakeProvider{
peers: []PeerResponse{
{NodeID: "foo-2", Target: "http://foo-2:8090", Up: true, Ordinal: 2},
{NodeID: "foo-0", Target: "http://foo-0:8090", Up: true, Ordinal: 0},
{NodeID: "foo-1", Target: "http://foo-1:8090", Up: false, Ordinal: 1},
},
}
rr := httptest.NewRecorder()
HandlePeers(p)(rr, httptest.NewRequest(http.MethodGet, "/_ha/peers", nil))
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rr.Code)
}
var got []PeerResponse
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 3 {
t.Fatalf("want 3 peers, got %d", len(got))
}
// The fake provider returns peers as-is; the handler does not re-sort,
// so this test asserts we expose whatever the provider sends. The real
// provider (QuasarWriterProvider.Peers) sorts by ordinal — covered in
// TestQuasarProviderPeersSorted.
_ = got
}
func TestOrdinalFromNodeID(t *testing.T) {
cases := []struct {
in string
want int
}{
{"foo-0", 0},
{"foo-1", 1},
{"foo-bar-12", 12},
{"foo", -1},
{"foo-", -1},
{"foo-x", -1},
}
for _, c := range cases {
if got := ordinalFromNodeID(c.in); got != c.want {
t.Errorf("ordinalFromNodeID(%q): want %d, got %d", c.in, c.want, got)
}
}
}
func TestWriterHeaderCacheIsAtomic(t *testing.T) {
setWriterHeader("http://a:1", 7)
url, term := currentWriterHeader()
if url != "http://a:1" || term != 7 {
t.Fatalf("got %q/%d", url, term)
}
setWriterHeader("http://b:1", 8)
url, term = currentWriterHeader()
if url != "http://b:1" || term != 8 {
t.Fatalf("got %q/%d", url, term)
}
}