use automerge::{AutomergeError, ROOT, ReadDoc, transaction::Transactable};
use samod_core::{
BackoffConfig, DialerConfig, DocSearchPhase, PeerRequestState, network::ConnectionEvent,
};
use samod_test_harness::{Connected, Network, RunningDocIds};
fn init_logging() {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init();
}
#[test]
fn find_after_create() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice_Original");
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
let mut tx = doc.transaction();
tx.put(automerge::ROOT, "foo", "bar").unwrap();
tx.commit();
})
.expect("with_document should succeed");
network.run_until_quiescent();
let alice_storage = network.samod(&alice).storage().clone();
let alice2 = network.create_samod_with_storage("Alice_Second", alice_storage);
let actor_id2 = network.samod(&alice2).find_document(&doc_id).unwrap();
let result = {
network
.samod(&alice2)
.with_document_by_actor(actor_id2, |doc| {
doc.get(automerge::ROOT, "foo")
.unwrap()
.map(|(value, _)| match value {
automerge::Value::Scalar(s) => match s.as_ref() {
automerge::ScalarValue::Str(string) => string.to_string(),
_ => s.to_string(),
},
_ => value.to_string(),
})
.unwrap_or_default()
})
.expect("with_document should succeed")
};
assert_eq!(result, "bar");
}
#[test]
fn three_peer_chain_sync() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
let charlie = network.create_samod("Charlie");
network.connect(alice, bob);
network.connect(bob, charlie);
network.run_until_quiescent();
for (name, peer_id) in [("Alice", alice), ("Bob", bob), ("Charlie", charlie)] {
let events = network.samod(&peer_id).connection_events();
let handshake_completed = events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
assert!(handshake_completed, "{name}'s handshake should complete");
}
let RunningDocIds { doc_id, actor_id } = network.samod(&charlie).create_document();
let result = network
.samod(&charlie)
.with_document_by_actor(actor_id, |doc| {
let mut tx = doc.transaction();
tx.put(automerge::ROOT, "creator", "charlie").unwrap();
tx.put(automerge::ROOT, "message", "hello from charlie")
.unwrap();
tx.commit();
"change_applied"
})
.unwrap();
assert_eq!(result, "change_applied");
network.run_until_quiescent();
let alice_actor_id = network.samod(&alice).find_document(&doc_id);
assert!(
alice_actor_id.is_some(),
"Alice should find the document through Bob from Charlie"
);
let alice_actor_id = alice_actor_id.unwrap();
let verification_result = network
.samod(&alice)
.with_document_by_actor(alice_actor_id, |doc| {
let creator = doc
.get(automerge::ROOT, "creator")
.unwrap()
.map(|(value, _)| value.into_string().unwrap())
.unwrap_or_default();
let message = doc
.get(automerge::ROOT, "message")
.unwrap()
.map(|(value, _)| value.into_string().unwrap())
.unwrap_or_default();
(creator, message)
})
.expect("with_document should succeed");
assert_eq!(verification_result.0, "charlie");
assert_eq!(verification_result.1, "hello from charlie");
let bob_actor_id = network.samod(&bob).find_document(&doc_id);
assert!(bob_actor_id.is_some(), "Bob should also have the document");
}
#[test]
fn document_persistence_across_restart() {
init_logging();
let mut network = Network::new();
let alice_original = network.create_samod("Alice_Original");
let bob = network.create_samod("Bob");
let RunningDocIds { doc_id, actor_id } = network.samod(&alice_original).create_document();
let result = {
network
.samod(&alice_original)
.with_document_by_actor(actor_id, |doc| {
let mut tx = doc.transaction();
tx.put(automerge::ROOT, "persistent_data", "survives_restart")
.unwrap();
tx.commit();
"data_added"
})
.expect("with_document should succeed")
};
assert_eq!(result, "data_added");
let alice_storage = network.samod(&alice_original).storage().clone();
let alice_restarted = network.create_samod_with_storage("Alice_Restarted", alice_storage);
network.connect(alice_restarted, bob);
network.run_until_quiescent();
let alice_events = network.samod(&alice_restarted).connection_events();
let bob_events = network.samod(&bob).connection_events();
let alice_handshake_completed = alice_events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
let bob_handshake_completed = bob_events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
assert!(
alice_handshake_completed,
"Restarted Alice's handshake should complete"
);
assert!(bob_handshake_completed, "Bob's handshake should complete");
let bob_actor_id = network
.samod(&bob)
.find_document(&doc_id)
.expect("document should be found on bob");
let verification_result = network
.samod(&bob)
.with_document_by_actor(bob_actor_id, |doc| {
println!("🔍 Bob's document keys: {:?}", doc.keys(automerge::ROOT));
doc.get(automerge::ROOT, "persistent_data")
.unwrap()
.map(|(value, _)| match value {
automerge::Value::Scalar(s) => match s.as_ref() {
automerge::ScalarValue::Str(string) => string.to_string(),
_ => s.to_string(),
},
_ => value.to_string(),
})
.unwrap_or_default()
})
.expect("with_document should succeed");
assert_eq!(verification_result, "survives_restart");
}
#[test]
fn unavailable_document_multiple_peers() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
let charlie = network.create_samod("Charlie");
network.connect(alice, bob);
network.connect(alice, charlie);
network.connect(bob, charlie);
network.run_until_quiescent();
println!("✅ All three peers connected to each other");
let fake_doc_id = samod_core::DocumentId::new(&mut rand::rng());
let alice_result = network.samod(&alice).find_document(&fake_doc_id);
assert!(alice_result.is_none(), "Document should not be found");
}
#[test]
fn unavailable_document_single_peer() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let fake_doc_id = samod_core::DocumentId::new(&mut rand::rng());
let alice_result = network.samod(&alice).find_document(&fake_doc_id);
assert!(alice_result.is_none(), "Document should not be found");
}
#[test]
fn request_document_before_connection() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
let result = network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
let mut tx = doc.transaction();
tx.put(
automerge::ROOT,
"delayed_sync",
"should_work_after_connection",
)
.unwrap();
tx.commit();
"data_added"
})
.expect("with document should succeed");
assert_eq!(result, "data_added");
let bob_result = network.samod(&bob).find_document(&doc_id);
assert!(
bob_result.is_none(),
"document should not be found before connection"
);
network.connect(alice, bob);
network.run_until_quiescent();
let alice_events = network.samod(&alice).connection_events();
let bob_events = network.samod(&bob).connection_events();
let alice_handshake_completed = alice_events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
let bob_handshake_completed = bob_events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
assert!(
alice_handshake_completed,
"Alice's handshake should complete"
);
assert!(bob_handshake_completed, "Bob's handshake should complete");
let bob_actor_id = network
.samod(&bob)
.find_document(&doc_id)
.expect("document should be found on bob after connection");
let verification_result = network
.samod(&bob)
.with_document_by_actor(bob_actor_id, |doc| {
doc.get(automerge::ROOT, "delayed_sync")
.unwrap()
.map(|(value, _)| match value {
automerge::Value::Scalar(s) => match s.as_ref() {
automerge::ScalarValue::Str(string) => string.to_string(),
_ => s.to_string(),
},
_ => value.to_string(),
})
.unwrap_or_default()
})
.expect("with_document should succeed");
assert_eq!(verification_result, "should_work_after_connection");
}
#[test]
fn peer_with_announce_policy_set_to_true_should_announce_to_peers() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
network.connect(alice, bob);
network.run_until_quiescent();
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap()
})
.unwrap();
network.run_until_quiescent();
network.disconnect(alice, bob);
assert!(network.samod(&bob).find_document(&doc_id).is_some());
}
#[test]
fn peer_with_announce_policy_set_to_false_does_not_announce() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
network
.samod(&alice)
.set_announce_policy(Box::new(|_doc_id, _peer_id| false));
let bob = network.create_samod("Bob");
network.connect(alice, bob);
network.run_until_quiescent();
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap()
})
.unwrap();
network.run_until_quiescent();
network.disconnect(alice, bob);
assert!(network.samod(&bob).find_document(&doc_id).is_none());
}
#[test]
fn peer_with_announce_policy_set_to_false_does_not_request() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
network
.samod(&alice)
.set_announce_policy(Box::new(|_doc_id, _peer_id| false));
let bob = network.create_samod("Bob");
network
.samod(&bob)
.set_announce_policy(Box::new(|_, _| false));
network.connect(alice, bob);
network.run_until_quiescent();
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap()
})
.unwrap();
network.run_until_quiescent();
assert!(network.samod(&bob).find_document(&doc_id).is_none());
}
#[test]
fn sync_while_requesting() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("alice");
let bob = network.create_samod("bob");
let charlie = network.create_samod("charlie");
let derek = network.create_samod("derek");
network.connect(alice, bob);
network.connect(bob, charlie);
network.connect(charlie, derek);
network.run_until_quiescent();
let RunningDocIds { doc_id, .. } = network.samod(&alice).create_document();
network.samod(&derek).search_for_doc(&doc_id);
network.run_until_quiescent();
assert!(
network.samod(&derek).is_document_available(&doc_id),
"document should eventually be available on derek"
)
}
#[test]
fn create_while_connected() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("alice");
let bob = network.create_samod("bob");
network.connect(alice, bob);
network.run_until_quiescent();
let RunningDocIds { doc_id, .. } = network.samod(&alice).create_document();
network
.samod(&alice)
.with_document(&doc_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(ROOT, "foo", "bar")?;
Ok(())
})
.unwrap();
})
.unwrap();
let alice_peer_id = network.samod(&alice).peer_id();
let bob_peer_id = network.samod(&bob).peer_id();
network
.run_until_message_received_at(alice_peer_id, bob_peer_id)
.unwrap();
network.samod(&bob).search_for_doc(&doc_id);
network.run_until_quiescent();
assert!(
network.samod(&bob).is_document_available(&doc_id),
"bob should have the doc"
);
}
#[test]
fn three_chained_sync_servers() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
network
.samod(&alice)
.set_announce_policy(Box::new(|_, _| false));
let alice_peer_id = network.samod(&alice).peer_id();
let bob = network.create_samod("Bob");
network
.samod(&bob)
.set_announce_policy(Box::new(move |_, peer_id| peer_id == alice_peer_id));
let bob_peer_id = network.samod(&bob).peer_id();
let charlie = network.create_samod("Charlie");
network
.samod(&charlie)
.set_announce_policy(Box::new(move |_, peer_id| peer_id == bob_peer_id));
network.connect(alice, bob);
network.connect(bob, charlie);
network.run_until_quiescent();
for (name, peer_id) in [("Alice", alice), ("Bob", bob), ("Charlie", charlie)] {
let events = network.samod(&peer_id).connection_events();
let handshake_completed = events
.iter()
.any(|event| matches!(event, ConnectionEvent::HandshakeCompleted { .. }));
assert!(handshake_completed, "{name}'s handshake should complete");
}
let RunningDocIds { doc_id, actor_id } = network.samod(&alice).create_document();
let result = network
.samod(&alice)
.with_document_by_actor(actor_id, |doc| {
let mut tx = doc.transaction();
tx.put(automerge::ROOT, "creator", "alice").unwrap();
tx.put(automerge::ROOT, "message", "hello from alice")
.unwrap();
tx.commit();
"change_applied"
})
.unwrap();
assert_eq!(result, "change_applied");
network.run_until_quiescent();
let charlie_actor_id = network.samod(&charlie).find_document(&doc_id);
assert!(
charlie_actor_id.is_some(),
"Charlie should find the document through Bob from Charlie"
);
let charlie_actor_id = charlie_actor_id.unwrap();
let verification_result = network
.samod(&charlie)
.with_document_by_actor(charlie_actor_id, |doc| {
let creator = doc
.get(automerge::ROOT, "creator")
.unwrap()
.map(|(value, _)| value.into_string().unwrap())
.unwrap_or_default();
let message = doc
.get(automerge::ROOT, "message")
.unwrap()
.map(|(value, _)| value.into_string().unwrap())
.unwrap_or_default();
(creator, message)
})
.expect("with_document should succeed");
assert_eq!(verification_result.0, "alice");
assert_eq!(verification_result.1, "hello from alice");
}
#[test]
fn dont_announce_policy_retains_documents_synced_by_clients() {
init_logging();
let mut network = Network::new();
let server = network.create_samod("Server");
network
.samod(&server)
.set_announce_policy(Box::new(|_, _| false));
let client = network.create_samod("Client");
let RunningDocIds { doc_id, actor_id } = network.samod(&client).create_document();
network
.samod(&client)
.with_document_by_actor(actor_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap()
})
.unwrap();
network.run_until_quiescent();
network.connect(client, server);
network.run_until_quiescent();
let server_actor = network.samod(&server).find_document(&doc_id);
assert!(server_actor.is_some(), "Server should have the document");
let server_actor = server_actor.unwrap();
let val = network
.samod(&server)
.with_document_by_actor(server_actor, |doc| {
doc.get(automerge::ROOT, "foo")
.unwrap()
.map(|(v, _)| v.to_string())
})
.unwrap();
assert_eq!(val.as_deref(), Some("\"bar\""));
}
#[test]
fn find_doesnt_bounce_through_unavailable_when_receiving_doc() {
init_logging();
let mut network = Network::new();
let server = network.create_samod("Server");
network
.samod(&server)
.set_announce_policy(Box::new(|_, _| false));
let client = network.create_samod("Client");
network.connect(client, server);
network.run_until_quiescent();
let RunningDocIds { doc_id, actor_id } = network.samod(&client).create_document();
network
.samod(&client)
.with_document_by_actor(actor_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap()
})
.unwrap();
network.samod(&server).search_for_doc(&doc_id);
network.samod(&server).pause_storage();
assert!(!network.samod(&server).is_document_available(&doc_id));
network.run_until_quiescent();
network.samod(&server).resume_storage();
network.run_until_quiescent();
assert!(
network.samod(&server).is_document_available(&doc_id),
"document should be found on server"
);
}
#[test]
fn search_status_from_requesting_to_ready() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
network
.samod(&alice)
.set_announce_policy(Box::new(|_, _| false));
let bob = network.create_samod("Bob");
let charlie = network.create_samod("Charlie");
let Connected {
left: _,
right: alice_from_bob,
} = network.connect(alice, bob);
let Connected {
left: charlie_from_bob,
right: _,
} = network.connect(bob, charlie);
network.run_until_quiescent();
let doc_id = network.samod(&alice).create_document().doc_id;
network
.samod(&alice)
.with_document(&doc_id, |doc| {
doc.transact::<_, _, AutomergeError>(|tx| {
tx.put(automerge::ROOT, "foo", "bar")?;
Ok(())
})
.unwrap();
})
.unwrap();
network.run_until_quiescent();
network.samod(&bob).search_for_doc(&doc_id);
let DocSearchPhase::Searching(peer_states) = network
.samod(&bob)
.search_status(&doc_id)
.expect("there should be a search state")
.phase()
.clone()
else {
panic!("document should be in searching state");
};
println!("{:?}", peer_states);
assert_eq!(
peer_states.len(),
2,
"peer states should show both peers as requesting"
);
assert_eq!(
peer_states
.get(&alice_from_bob)
.expect("alice should be in bobs peer states"),
&PeerRequestState::Requested,
"alice should be in requested state"
);
assert_eq!(
peer_states
.get(&charlie_from_bob)
.expect("charlie should be in bobs peer states"),
&PeerRequestState::Requested,
"charlie should be in requested state"
);
assert!(
!network
.samod(&bob)
.search_status(&doc_id)
.unwrap()
.is_currently_unavailable(),
"document should not be currently unavailable whilst we're requesting"
);
network.run_until_quiescent();
let DocSearchPhase::Ready = network
.samod(&bob)
.search_status(&doc_id)
.expect("there should be a search state")
.phase()
.clone()
else {
panic!("document should be in synced state");
};
}
#[test]
fn search_state_not_found_if_no_one_has_doc() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
let RunningDocIds { doc_id, .. } = network.samod(&alice).create_document();
network.samod(&bob).search_for_doc(&doc_id);
network.run_until_quiescent();
assert!(
network
.samod(&bob)
.search_status(&doc_id)
.expect("search state should be found")
.is_currently_unavailable(),
"search should be in a not currently available state if no one has the document"
);
}
#[test]
fn search_state_not_unavailable_after_dialer_added() {
init_logging();
let mut network = Network::new();
let alice = network.create_samod("Alice");
let bob = network.create_samod("Bob");
let RunningDocIds { doc_id, .. } = network.samod(&alice).create_document();
network.samod(&bob).search_for_doc(&doc_id);
network.run_until_quiescent();
network.samod(&bob).add_dialer(DialerConfig {
url: url::Url::parse("wss://sync.example.com/automerge").unwrap(),
backoff: BackoffConfig::default(),
});
assert!(
!network
.samod(&bob)
.search_status(&doc_id)
.expect("search state should be found")
.is_currently_unavailable(),
"search should not be in a currently unavailable state if a dialer is still connecting"
);
}