package election import ( "context" "testing" "time" "chorus.services/bzzz/pkg/config" ) func TestElectionManager_NewElectionManager(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) if em == nil { t.Fatal("Expected NewElectionManager to return non-nil manager") } if em.nodeID != "test-node" { t.Errorf("Expected nodeID to be 'test-node', got %s", em.nodeID) } if em.state != StateIdle { t.Errorf("Expected initial state to be StateIdle, got %v", em.state) } } func TestElectionManager_StartElection(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Start election err := em.StartElection() if err != nil { t.Fatalf("Failed to start election: %v", err) } // Verify state changed if em.state != StateCandidate { t.Errorf("Expected state to be StateCandidate after starting election, got %v", em.state) } // Verify we added ourselves as a candidate em.mu.RLock() candidate, exists := em.candidates[em.nodeID] em.mu.RUnlock() if !exists { t.Error("Expected to find ourselves as a candidate after starting election") } if candidate.NodeID != em.nodeID { t.Errorf("Expected candidate NodeID to be %s, got %s", em.nodeID, candidate.NodeID) } } func TestElectionManager_Vote(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Add a candidate first candidate := &AdminCandidate{ NodeID: "candidate-1", Term: 1, Score: 0.8, Capabilities: []string{"admin"}, LastSeen: time.Now(), } em.mu.Lock() em.candidates["candidate-1"] = candidate em.mu.Unlock() // Vote for the candidate err := em.Vote("candidate-1") if err != nil { t.Fatalf("Failed to vote: %v", err) } // Verify vote was recorded em.mu.RLock() vote, exists := em.votes[em.nodeID] em.mu.RUnlock() if !exists { t.Error("Expected to find our vote after voting") } if vote != "candidate-1" { t.Errorf("Expected vote to be for 'candidate-1', got %s", vote) } } func TestElectionManager_VoteInvalidCandidate(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Try to vote for non-existent candidate err := em.Vote("non-existent") if err == nil { t.Error("Expected error when voting for non-existent candidate") } } func TestElectionManager_AddCandidate(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) candidate := &AdminCandidate{ NodeID: "new-candidate", Term: 1, Score: 0.7, Capabilities: []string{"admin", "leader"}, LastSeen: time.Now(), } err := em.AddCandidate(candidate) if err != nil { t.Fatalf("Failed to add candidate: %v", err) } // Verify candidate was added em.mu.RLock() stored, exists := em.candidates["new-candidate"] em.mu.RUnlock() if !exists { t.Error("Expected to find added candidate") } if stored.NodeID != "new-candidate" { t.Errorf("Expected stored candidate NodeID to be 'new-candidate', got %s", stored.NodeID) } if stored.Score != 0.7 { t.Errorf("Expected stored candidate score to be 0.7, got %f", stored.Score) } } func TestElectionManager_FindElectionWinner(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Add candidates with different scores candidates := []*AdminCandidate{ { NodeID: "candidate-1", Term: 1, Score: 0.6, Capabilities: []string{"admin"}, LastSeen: time.Now(), }, { NodeID: "candidate-2", Term: 1, Score: 0.8, Capabilities: []string{"admin", "leader"}, LastSeen: time.Now(), }, { NodeID: "candidate-3", Term: 1, Score: 0.7, Capabilities: []string{"admin"}, LastSeen: time.Now(), }, } em.mu.Lock() for _, candidate := range candidates { em.candidates[candidate.NodeID] = candidate } // Add some votes em.votes["voter-1"] = "candidate-2" em.votes["voter-2"] = "candidate-2" em.votes["voter-3"] = "candidate-1" em.mu.Unlock() // Find winner winner := em.findElectionWinner() if winner == nil { t.Fatal("Expected findElectionWinner to return a winner") } // candidate-2 should win with most votes (2 votes) if winner.NodeID != "candidate-2" { t.Errorf("Expected winner to be 'candidate-2', got %s", winner.NodeID) } } func TestElectionManager_FindElectionWinnerNoVotes(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Add candidates but no votes - should fall back to highest score candidates := []*AdminCandidate{ { NodeID: "candidate-1", Term: 1, Score: 0.6, Capabilities: []string{"admin"}, LastSeen: time.Now(), }, { NodeID: "candidate-2", Term: 1, Score: 0.9, // Highest score Capabilities: []string{"admin", "leader"}, LastSeen: time.Now(), }, } em.mu.Lock() for _, candidate := range candidates { em.candidates[candidate.NodeID] = candidate } em.mu.Unlock() // Find winner without any votes winner := em.findElectionWinner() if winner == nil { t.Fatal("Expected findElectionWinner to return a winner") } // candidate-2 should win with highest score if winner.NodeID != "candidate-2" { t.Errorf("Expected winner to be 'candidate-2' (highest score), got %s", winner.NodeID) } } func TestElectionManager_HandleElectionVote(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Add a candidate first candidate := &AdminCandidate{ NodeID: "candidate-1", Term: 1, Score: 0.8, Capabilities: []string{"admin"}, LastSeen: time.Now(), } em.mu.Lock() em.candidates["candidate-1"] = candidate em.mu.Unlock() // Create vote message msg := ElectionMessage{ Type: MessageTypeVote, NodeID: "voter-1", Data: map[string]interface{}{ "candidate": "candidate-1", }, } // Handle the vote em.handleElectionVote(msg) // Verify vote was recorded em.mu.RLock() vote, exists := em.votes["voter-1"] em.mu.RUnlock() if !exists { t.Error("Expected vote to be recorded after handling vote message") } if vote != "candidate-1" { t.Errorf("Expected recorded vote to be for 'candidate-1', got %s", vote) } } func TestElectionManager_HandleElectionVoteInvalidData(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Create vote message with invalid data msg := ElectionMessage{ Type: MessageTypeVote, NodeID: "voter-1", Data: "invalid-data", // Should be map[string]interface{} } // Handle the vote - should not crash em.handleElectionVote(msg) // Verify no vote was recorded em.mu.RLock() _, exists := em.votes["voter-1"] em.mu.RUnlock() if exists { t.Error("Expected no vote to be recorded with invalid data") } } func TestElectionManager_CompleteElection(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Set up election state em.mu.Lock() em.state = StateCandidate em.currentTerm = 1 em.mu.Unlock() // Add a candidate candidate := &AdminCandidate{ NodeID: "winner", Term: 1, Score: 0.9, Capabilities: []string{"admin", "leader"}, LastSeen: time.Now(), } em.mu.Lock() em.candidates["winner"] = candidate em.mu.Unlock() // Complete election em.CompleteElection() // Verify state reset em.mu.RLock() state := em.state em.mu.RUnlock() if state != StateIdle { t.Errorf("Expected state to be StateIdle after completing election, got %v", state) } } func TestElectionManager_Concurrency(t *testing.T) { cfg := &config.Config{ Agent: config.AgentConfig{ ID: "test-node", }, } em := NewElectionManager(cfg) // Test concurrent access to vote and candidate operations ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // Add a candidate candidate := &AdminCandidate{ NodeID: "candidate-1", Term: 1, Score: 0.8, Capabilities: []string{"admin"}, LastSeen: time.Now(), } err := em.AddCandidate(candidate) if err != nil { t.Fatalf("Failed to add candidate: %v", err) } // Run concurrent operations done := make(chan bool, 2) // Concurrent voting go func() { defer func() { done <- true }() for i := 0; i < 10; i++ { select { case <-ctx.Done(): return default: em.Vote("candidate-1") // Ignore errors in concurrent test time.Sleep(10 * time.Millisecond) } } }() // Concurrent state checking go func() { defer func() { done <- true }() for i := 0; i < 10; i++ { select { case <-ctx.Done(): return default: em.findElectionWinner() // Just check for races time.Sleep(10 * time.Millisecond) } } }() // Wait for completion for i := 0; i < 2; i++ { select { case <-done: case <-ctx.Done(): t.Fatal("Concurrent test timed out") } } }