mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
287 lines
8.5 KiB
Rust
287 lines
8.5 KiB
Rust
//! Integration tests for cluster coordination using actual implementations
|
|
//!
|
|
//! Tests use local-mode `NodeRegistry` (no Redis) to validate cluster coordination
|
|
//! logic without external dependencies.
|
|
//!
|
|
//! Run with: cargo test --test `cluster_coordination_tests`
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use synctv_cluster::discovery::{
|
|
HealthMonitor, LoadBalancer, LoadBalancingStrategy, NodeInfo, NodeRegistry,
|
|
};
|
|
|
|
/// Helper: create a local-only `NodeRegistry` for coordination logic tests.
|
|
fn make_registry(node_id: &str) -> Arc<NodeRegistry> {
|
|
Arc::new(NodeRegistry::new_local_only(node_id.to_string(), 30, "test:").unwrap())
|
|
}
|
|
|
|
// NodeInfo tests
|
|
|
|
#[tokio::test]
|
|
async fn test_node_info_serialization() {
|
|
let node = NodeInfo {
|
|
node_id: "node-123".to_string(),
|
|
cluster_address: "127.0.0.1:50051".to_string(),
|
|
epoch: 0,
|
|
last_heartbeat: chrono::Utc::now(),
|
|
metadata: Default::default(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&node).expect("Failed to serialize");
|
|
let deserialized: NodeInfo = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(node.node_id, deserialized.node_id);
|
|
assert_eq!(node.cluster_address, deserialized.cluster_address);
|
|
assert_eq!(node.epoch, deserialized.epoch);
|
|
}
|
|
|
|
// NodeRegistry tests (actual implementation, local mode)
|
|
|
|
#[tokio::test]
|
|
async fn test_node_registry_register_and_get() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let node = registry.get_node_local("self").await;
|
|
assert!(node.is_some());
|
|
let node = node.unwrap();
|
|
assert_eq!(node.node_id, "self");
|
|
assert_eq!(node.cluster_address, "localhost:50051");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_node_registry_concurrent_registration() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
// Register 9 additional remote nodes concurrently
|
|
let mut handles = vec![];
|
|
for i in 1..10 {
|
|
let reg = registry.clone();
|
|
let handle = tokio::spawn(async move {
|
|
let node = NodeInfo::new(format!("node-{i}"), format!("localhost:{}", 8080 + i));
|
|
reg.test_insert_local(node).await;
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
futures::future::join_all(handles).await;
|
|
|
|
let all_nodes = registry.get_all_nodes_local().await;
|
|
assert_eq!(all_nodes.len(), 10);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_node_registry_unregister() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let remote = NodeInfo::new("remote-1".to_string(), "localhost:8081".to_string());
|
|
registry.test_insert_local(remote).await;
|
|
|
|
assert_eq!(registry.get_all_nodes_local().await.len(), 2);
|
|
|
|
// Unregister the remote node
|
|
registry.test_remove_local("remote-1").await;
|
|
assert_eq!(registry.get_all_nodes_local().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_node_registry_get_nonexistent() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let node = registry.get_node_local("nonexistent").await;
|
|
assert!(node.is_none());
|
|
}
|
|
|
|
// HealthMonitor tests (actual implementation)
|
|
|
|
#[tokio::test]
|
|
async fn test_health_monitor_initial_state() {
|
|
let registry = make_registry("self");
|
|
let monitor = HealthMonitor::new(registry, 60);
|
|
|
|
let status = monitor.get_all_status().await;
|
|
assert!(status.is_empty());
|
|
assert_eq!(monitor.get_node_status("self").await, None);
|
|
}
|
|
|
|
// LoadBalancer tests (actual implementation, local mode)
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_empty_cluster_returns_error() {
|
|
let registry = make_registry("self");
|
|
let lb = LoadBalancer::new(registry, LoadBalancingStrategy::Random);
|
|
assert!(lb.select_node().await.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_single_node() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let lb = LoadBalancer::new(Arc::clone(®istry), LoadBalancingStrategy::Random);
|
|
let node = lb.select_node().await.unwrap();
|
|
assert_eq!(node, "self");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_round_robin_cycles() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
for i in 1..3 {
|
|
let node = NodeInfo::new(format!("node-{i}"), format!("localhost:{}", 8080 + i));
|
|
registry.test_insert_local(node).await;
|
|
}
|
|
|
|
let lb = LoadBalancer::new(Arc::clone(®istry), LoadBalancingStrategy::RoundRobin);
|
|
|
|
// Collect first cycle
|
|
let mut first_cycle = Vec::new();
|
|
for _ in 0..3 {
|
|
first_cycle.push(lb.select_node().await.unwrap());
|
|
}
|
|
|
|
// Should get all 3 unique nodes
|
|
let unique: HashSet<_> = first_cycle.iter().collect();
|
|
assert_eq!(
|
|
unique.len(),
|
|
3,
|
|
"Round-robin should visit all nodes in one cycle"
|
|
);
|
|
|
|
// Second cycle should match the first (deterministic)
|
|
let mut second_cycle = Vec::new();
|
|
for _ in 0..3 {
|
|
second_cycle.push(lb.select_node().await.unwrap());
|
|
}
|
|
assert_eq!(first_cycle, second_cycle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_random_distributes() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
for i in 1..5 {
|
|
let node = NodeInfo::new(format!("node-{i}"), format!("localhost:{}", 8080 + i));
|
|
registry.test_insert_local(node).await;
|
|
}
|
|
|
|
let lb = LoadBalancer::new(Arc::clone(®istry), LoadBalancingStrategy::Random);
|
|
|
|
let mut selected = HashSet::new();
|
|
for _ in 0..100 {
|
|
selected.insert(lb.select_node().await.unwrap());
|
|
}
|
|
|
|
assert!(
|
|
selected.len() >= 2,
|
|
"Random should hit multiple nodes over 100 selections"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_with_health_monitor_no_status() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let remote = NodeInfo::new("node-1".to_string(), "localhost:8081".to_string());
|
|
registry.test_insert_local(remote).await;
|
|
|
|
// Monitor has no statuses yet -- all nodes should be available
|
|
let monitor = Arc::new(HealthMonitor::new(Arc::clone(®istry), 60));
|
|
let lb = LoadBalancer::new(Arc::clone(®istry), LoadBalancingStrategy::RoundRobin)
|
|
.with_health_runtime(monitor);
|
|
|
|
let available = lb.get_available_nodes().await.unwrap();
|
|
assert_eq!(
|
|
available.len(),
|
|
2,
|
|
"All nodes available when health monitor has no data"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_balancer_select_by_id() {
|
|
let registry = make_registry("self");
|
|
registry
|
|
.test_insert_local(NodeInfo::new(
|
|
"self".to_string(),
|
|
"localhost:50051".to_string(),
|
|
))
|
|
.await;
|
|
|
|
let lb = LoadBalancer::new(Arc::clone(®istry), LoadBalancingStrategy::Random);
|
|
|
|
assert!(lb.select_node_by_id("self").await.is_ok());
|
|
assert!(lb.select_node_by_id("nonexistent").await.is_err());
|
|
}
|
|
|
|
// Quorum logic (algorithmic test, no mock -- pure function)
|
|
|
|
#[tokio::test]
|
|
async fn test_quorum_validation() {
|
|
fn has_quorum(alive_nodes: usize, total_nodes: usize) -> bool {
|
|
alive_nodes > total_nodes / 2
|
|
}
|
|
|
|
// 5 node cluster
|
|
assert!(has_quorum(3, 5), "3/5 nodes have quorum");
|
|
assert!(has_quorum(4, 5), "4/5 nodes have quorum");
|
|
assert!(has_quorum(5, 5), "5/5 nodes have quorum");
|
|
assert!(!has_quorum(2, 5), "2/5 nodes do not have quorum");
|
|
assert!(!has_quorum(1, 5), "1/5 nodes do not have quorum");
|
|
|
|
// 3 node cluster
|
|
assert!(has_quorum(2, 3), "2/3 nodes have quorum");
|
|
assert!(!has_quorum(1, 3), "1/3 nodes do not have quorum");
|
|
|
|
// Single node (always has quorum)
|
|
assert!(has_quorum(1, 1), "Single node has quorum");
|
|
}
|