187 lines
		
	
	
		
			4.1 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			187 lines
		
	
	
		
			4.1 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package election
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"testing"
 | |
| 	"time"
 | |
| 
 | |
| 	"chorus/pkg/config"
 | |
| 	pubsubpkg "chorus/pubsub"
 | |
| 	libp2p "github.com/libp2p/go-libp2p"
 | |
| )
 | |
| 
 | |
| // newTestElectionManager wires a real libp2p host and PubSub instance so the
 | |
| // election manager exercises the same code paths used in production.
 | |
| func newTestElectionManager(t *testing.T) *ElectionManager {
 | |
| 	t.Helper()
 | |
| 
 | |
| 	ctx, cancel := context.WithCancel(context.Background())
 | |
| 
 | |
| 	host, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))
 | |
| 	if err != nil {
 | |
| 		cancel()
 | |
| 		t.Fatalf("failed to create libp2p host: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	ps, err := pubsubpkg.NewPubSub(ctx, host, "", "")
 | |
| 	if err != nil {
 | |
| 		host.Close()
 | |
| 		cancel()
 | |
| 		t.Fatalf("failed to create pubsub: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	cfg := &config.Config{
 | |
| 		Agent: config.AgentConfig{
 | |
| 			ID:             host.ID().String(),
 | |
| 			Role:           "context_admin",
 | |
| 			Capabilities:   []string{"admin_election", "context_curation"},
 | |
| 			Models:         []string{"meta/llama-3.1-8b-instruct"},
 | |
| 			Specialization: "coordination",
 | |
| 		},
 | |
| 		Security: config.SecurityConfig{},
 | |
| 	}
 | |
| 
 | |
| 	em := NewElectionManager(ctx, cfg, host, ps, host.ID().String())
 | |
| 
 | |
| 	t.Cleanup(func() {
 | |
| 		em.Stop()
 | |
| 		ps.Close()
 | |
| 		host.Close()
 | |
| 		cancel()
 | |
| 	})
 | |
| 
 | |
| 	return em
 | |
| }
 | |
| 
 | |
| func TestNewElectionManagerInitialState(t *testing.T) {
 | |
| 	em := newTestElectionManager(t)
 | |
| 
 | |
| 	if em.state != StateIdle {
 | |
| 		t.Fatalf("expected initial state %q, got %q", StateIdle, em.state)
 | |
| 	}
 | |
| 
 | |
| 	if em.currentTerm != 0 {
 | |
| 		t.Fatalf("expected initial term 0, got %d", em.currentTerm)
 | |
| 	}
 | |
| 
 | |
| 	if em.nodeID == "" {
 | |
| 		t.Fatal("expected nodeID to be populated")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestElectionManagerCanBeAdmin(t *testing.T) {
 | |
| 	em := newTestElectionManager(t)
 | |
| 
 | |
| 	if !em.canBeAdmin() {
 | |
| 		t.Fatal("expected node to qualify for admin election")
 | |
| 	}
 | |
| 
 | |
| 	em.config.Agent.Capabilities = []string{"runtime_support"}
 | |
| 	if em.canBeAdmin() {
 | |
| 		t.Fatal("expected node without admin capabilities to be ineligible")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestFindElectionWinnerPrefersVotesThenScore(t *testing.T) {
 | |
| 	em := newTestElectionManager(t)
 | |
| 
 | |
| 	em.mu.Lock()
 | |
| 	em.candidates = map[string]*AdminCandidate{
 | |
| 		"candidate-1": {
 | |
| 			NodeID: "candidate-1",
 | |
| 			PeerID: em.host.ID(),
 | |
| 			Score:  0.65,
 | |
| 		},
 | |
| 		"candidate-2": {
 | |
| 			NodeID: "candidate-2",
 | |
| 			PeerID: em.host.ID(),
 | |
| 			Score:  0.80,
 | |
| 		},
 | |
| 	}
 | |
| 	em.votes = map[string]string{
 | |
| 		"voter-a": "candidate-1",
 | |
| 		"voter-b": "candidate-2",
 | |
| 		"voter-c": "candidate-2",
 | |
| 	}
 | |
| 	em.mu.Unlock()
 | |
| 
 | |
| 	winner := em.findElectionWinner()
 | |
| 	if winner == nil {
 | |
| 		t.Fatal("expected a winner to be selected")
 | |
| 	}
 | |
| 	if winner.NodeID != "candidate-2" {
 | |
| 		t.Fatalf("expected candidate-2 to win, got %s", winner.NodeID)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestHandleElectionMessageAddsCandidate(t *testing.T) {
 | |
| 	em := newTestElectionManager(t)
 | |
| 
 | |
| 	em.mu.Lock()
 | |
| 	em.currentTerm = 3
 | |
| 	em.state = StateElecting
 | |
| 	em.mu.Unlock()
 | |
| 
 | |
| 	candidate := &AdminCandidate{
 | |
| 		NodeID:       "peer-2",
 | |
| 		PeerID:       em.host.ID(),
 | |
| 		Capabilities: []string{"admin_election"},
 | |
| 		Uptime:       time.Second,
 | |
| 		Score:        0.75,
 | |
| 	}
 | |
| 
 | |
| 	payload, err := json.Marshal(candidate)
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("failed to marshal candidate: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	var data map[string]interface{}
 | |
| 	if err := json.Unmarshal(payload, &data); err != nil {
 | |
| 		t.Fatalf("failed to unmarshal candidate payload: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	msg := ElectionMessage{
 | |
| 		Type:      "candidacy_announcement",
 | |
| 		NodeID:    "peer-2",
 | |
| 		Timestamp: time.Now(),
 | |
| 		Term:      3,
 | |
| 		Data:      data,
 | |
| 	}
 | |
| 
 | |
| 	serialized, err := json.Marshal(msg)
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("failed to marshal election message: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	em.handleElectionMessage(serialized)
 | |
| 
 | |
| 	em.mu.RLock()
 | |
| 	_, exists := em.candidates["peer-2"]
 | |
| 	em.mu.RUnlock()
 | |
| 
 | |
| 	if !exists {
 | |
| 		t.Fatal("expected candidacy announcement to register candidate")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestSendAdminHeartbeatRequiresLeadership(t *testing.T) {
 | |
| 	em := newTestElectionManager(t)
 | |
| 
 | |
| 	if err := em.SendAdminHeartbeat(); err == nil {
 | |
| 		t.Fatal("expected error when non-admin sends heartbeat")
 | |
| 	}
 | |
| 
 | |
| 	if err := em.Start(); err != nil {
 | |
| 		t.Fatalf("failed to start election manager: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	em.mu.Lock()
 | |
| 	em.currentAdmin = em.nodeID
 | |
| 	em.mu.Unlock()
 | |
| 
 | |
| 	if err := em.SendAdminHeartbeat(); err != nil {
 | |
| 		t.Fatalf("expected heartbeat to succeed for current admin, got error: %v", err)
 | |
| 	}
 | |
| }
 | 
