#[cfg(test)]
mod tests {
use agentdb::sync::{ConflictStrategy, HybridClock, OpType, SyncEngine, SyncOp};
use agentdb::AgentDB;
use serde_json::json;
fn open_db() -> AgentDB {
AgentDB::open(":memory:").expect("failed to open in-memory db")
}
#[test]
fn test_hlc_monotonicity() {
let mut clock = HybridClock::new("node-a");
let t0 = clock.now();
let t1 = clock.now();
let t2 = clock.now();
assert!(t1 > t0, "successive HLC calls must be strictly increasing");
assert!(t2 > t1, "successive HLC calls must be strictly increasing");
}
#[test]
fn test_hlc_update_advances_past_remote() {
let mut clock = HybridClock::new("node-b");
let local_ts = clock.now();
let future_ms: i64 = 9_999_999_999_999_i64; let remote_ts = future_ms << 16;
clock.update(remote_ts);
let after = clock.now();
assert!(
after > remote_ts,
"clock should advance past remote ts: after={after} remote={remote_ts}"
);
assert!(remote_ts > local_ts);
}
#[test]
fn test_hlc_update_with_older_remote_does_not_regress() {
let mut clock = HybridClock::new("node-c");
let t0 = clock.now();
clock.update(0); let t1 = clock.now();
assert!(t1 > t0, "clock must not regress after receiving an old remote ts");
}
#[test]
fn test_record_mutation_is_retrievable() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-1").unwrap();
let op_id = engine
.record_mutation(
"my_table",
"row-1",
OpType::Insert,
Some(json!({"name": "Alice"})),
)
.unwrap();
assert!(!op_id.is_empty(), "op_id should be a non-empty UUID string");
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
let op = &ops[0];
assert_eq!(op.op_id, op_id);
assert_eq!(op.table_name, "my_table");
assert_eq!(op.record_id, "row-1");
assert_eq!(op.op_type, OpType::Insert);
assert_eq!(op.payload.as_ref().unwrap()["name"], "Alice");
assert_eq!(op.node_id, "node-1");
}
#[test]
fn test_get_ops_since_filters_by_hlc() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-1").unwrap();
engine
.record_mutation("t", "r1", OpType::Insert, None)
.unwrap();
let mid_ops = engine.get_ops_since(0).unwrap();
let watermark = mid_ops[0].hlc_ts;
engine
.record_mutation("t", "r2", OpType::Insert, None)
.unwrap();
engine
.record_mutation("t", "r3", OpType::Insert, None)
.unwrap();
let later = engine.get_ops_since(watermark).unwrap();
assert_eq!(later.len(), 2, "only ops after watermark should be returned");
for op in &later {
assert!(op.hlc_ts > watermark);
}
}
#[test]
fn test_record_mutation_delete_no_payload() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-1").unwrap();
engine
.record_mutation("users", "u-99", OpType::Delete, None)
.unwrap();
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].op_type, OpType::Delete);
assert!(ops[0].payload.is_none());
}
#[test]
fn test_apply_remote_ops_no_conflict() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
let remote_op = SyncOp {
op_id: "op-remote-1".to_string(),
hlc_ts: 100 << 16, node_id: "node-remote".to_string(),
table_name: "items".to_string(),
record_id: "item-1".to_string(),
op_type: OpType::Insert,
payload: Some(json!({"value": 42})),
};
let result = engine.apply_remote_ops(vec![remote_op]).unwrap();
assert_eq!(result.applied, 1);
assert_eq!(result.conflicts, 0);
assert_eq!(result.skipped, 0);
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].op_id, "op-remote-1");
}
#[test]
fn test_apply_multiple_remote_ops_no_conflict() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
let remote_ops: Vec<SyncOp> = (0..5)
.map(|i| SyncOp {
op_id: format!("op-{i}"),
hlc_ts: (100 + i) << 16,
node_id: "node-remote".to_string(),
table_name: "tbl".to_string(),
record_id: format!("row-{i}"),
op_type: OpType::Insert,
payload: None,
})
.collect();
let result = engine.apply_remote_ops(remote_ops).unwrap();
assert_eq!(result.applied, 5);
assert_eq!(result.conflicts, 0);
assert_eq!(result.skipped, 0);
}
#[test]
fn test_lww_incoming_higher_ts_wins() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
engine.set_strategy(ConflictStrategy::LastWriterWins);
let local_op = SyncOp {
op_id: "op-local".to_string(),
hlc_ts: 10 << 16,
node_id: "node-local".to_string(),
table_name: "docs".to_string(),
record_id: "doc-1".to_string(),
op_type: OpType::Insert,
payload: Some(json!({"v": "local"})),
};
engine.apply_remote_ops(vec![local_op]).unwrap();
let remote_op = SyncOp {
op_id: "op-remote".to_string(),
hlc_ts: 20 << 16,
node_id: "node-remote".to_string(),
table_name: "docs".to_string(),
record_id: "doc-1".to_string(),
op_type: OpType::Update,
payload: Some(json!({"v": "remote"})),
};
let result = engine.apply_remote_ops(vec![remote_op]).unwrap();
assert_eq!(result.applied, 1);
assert_eq!(result.conflicts, 1);
assert_eq!(result.skipped, 0);
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].op_id, "op-remote");
assert_eq!(ops[0].payload.as_ref().unwrap()["v"], "remote");
}
#[test]
fn test_lww_incoming_lower_ts_loses() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
engine.set_strategy(ConflictStrategy::LastWriterWins);
let local_op = SyncOp {
op_id: "op-local-high".to_string(),
hlc_ts: 50 << 16,
node_id: "node-local".to_string(),
table_name: "docs".to_string(),
record_id: "doc-2".to_string(),
op_type: OpType::Insert,
payload: Some(json!({"v": "local-high"})),
};
engine.apply_remote_ops(vec![local_op]).unwrap();
let remote_op = SyncOp {
op_id: "op-remote-old".to_string(),
hlc_ts: 5 << 16,
node_id: "node-remote".to_string(),
table_name: "docs".to_string(),
record_id: "doc-2".to_string(),
op_type: OpType::Update,
payload: Some(json!({"v": "remote-old"})),
};
let result = engine.apply_remote_ops(vec![remote_op]).unwrap();
assert_eq!(result.applied, 0);
assert_eq!(result.conflicts, 1);
assert_eq!(result.skipped, 1);
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].op_id, "op-local-high");
}
#[test]
fn test_fww_incoming_lower_ts_wins() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
engine.set_strategy(ConflictStrategy::FirstWriterWins);
let local_op = SyncOp {
op_id: "op-local-late".to_string(),
hlc_ts: 50 << 16,
node_id: "node-local".to_string(),
table_name: "notes".to_string(),
record_id: "note-1".to_string(),
op_type: OpType::Insert,
payload: Some(json!({"text": "late local"})),
};
engine.apply_remote_ops(vec![local_op]).unwrap();
let remote_op = SyncOp {
op_id: "op-remote-early".to_string(),
hlc_ts: 5 << 16,
node_id: "node-remote".to_string(),
table_name: "notes".to_string(),
record_id: "note-1".to_string(),
op_type: OpType::Insert,
payload: Some(json!({"text": "early remote"})),
};
let result = engine.apply_remote_ops(vec![remote_op]).unwrap();
assert_eq!(result.applied, 1);
assert_eq!(result.conflicts, 1);
assert_eq!(result.skipped, 0);
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].op_id, "op-remote-early");
assert_eq!(ops[0].payload.as_ref().unwrap()["text"], "early remote");
}
#[test]
fn test_add_peer_and_query_status() {
let db = open_db();
let engine = SyncEngine::new(&db, "node-local").unwrap();
engine
.add_peer("peer-alpha", Some("https://alpha.example.com/sync"))
.unwrap();
engine.add_peer("peer-beta", None).unwrap();
let statuses = engine.sync_status().unwrap();
assert_eq!(statuses.len(), 2);
let alpha = statuses.iter().find(|s| s.peer_id == "peer-alpha").unwrap();
assert_eq!(
alpha.endpoint.as_deref(),
Some("https://alpha.example.com/sync")
);
assert_eq!(alpha.last_synced_hlc, 0);
let beta = statuses.iter().find(|s| s.peer_id == "peer-beta").unwrap();
assert!(beta.endpoint.is_none());
}
#[test]
fn test_ops_pending_counts_unsynced_ops() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "node-local").unwrap();
engine.add_peer("peer-x", None).unwrap();
let statuses = engine.sync_status().unwrap();
assert_eq!(statuses[0].ops_pending, 0);
engine
.record_mutation("t", "r1", OpType::Insert, None)
.unwrap();
engine
.record_mutation("t", "r2", OpType::Insert, None)
.unwrap();
engine
.record_mutation("t", "r3", OpType::Update, None)
.unwrap();
let statuses = engine.sync_status().unwrap();
assert_eq!(
statuses[0].ops_pending, 3,
"all three ops are pending for peer-x"
);
}
#[test]
fn test_add_peer_idempotent_update() {
let db = open_db();
let engine = SyncEngine::new(&db, "node-local").unwrap();
engine.add_peer("peer-1", Some("http://old.endpoint")).unwrap();
engine
.add_peer("peer-1", Some("http://new.endpoint"))
.unwrap();
let statuses = engine.sync_status().unwrap();
assert_eq!(statuses.len(), 1, "duplicate add_peer should not double the row");
assert_eq!(
statuses[0].endpoint.as_deref(),
Some("http://new.endpoint"),
"endpoint should be updated on re-add"
);
}
#[test]
fn test_ops_node_id_matches_engine_node() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "my-unique-node").unwrap();
engine
.record_mutation("t", "r1", OpType::Insert, None)
.unwrap();
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops[0].node_id, "my-unique-node");
}
#[test]
fn test_hlc_ts_increases_across_record_mutation_calls() {
let db = open_db();
let mut engine = SyncEngine::new(&db, "n").unwrap();
engine.record_mutation("t", "r1", OpType::Insert, None).unwrap();
engine.record_mutation("t", "r2", OpType::Insert, None).unwrap();
engine.record_mutation("t", "r3", OpType::Insert, None).unwrap();
let ops = engine.get_ops_since(0).unwrap();
assert_eq!(ops.len(), 3);
assert!(
ops[0].hlc_ts < ops[1].hlc_ts,
"op 0 ts {} should be < op 1 ts {}",
ops[0].hlc_ts,
ops[1].hlc_ts
);
assert!(
ops[1].hlc_ts < ops[2].hlc_ts,
"op 1 ts {} should be < op 2 ts {}",
ops[1].hlc_ts,
ops[2].hlc_ts
);
}
}