use super::*;
use crate::lid_pn_cache::LearningSource;
use crate::test_utils::MockHttpClient;
use futures::channel::oneshot;
use wacore_binary::SERVER_JID;
#[tokio::test]
async fn test_ack_behavior_for_incoming_stanzas() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
use wacore_binary::{Attrs, Node, NodeContent};
let mut receipt_attrs = Attrs::new();
receipt_attrs.insert("from".to_string(), "@s.whatsapp.net".to_string());
receipt_attrs.insert("id".to_string(), "RCPT-1".to_string());
let receipt_node = Node::new(
"receipt",
receipt_attrs,
Some(NodeContent::String("test".into())),
);
let mut notification_attrs = Attrs::new();
notification_attrs.insert("from".to_string(), "@s.whatsapp.net".to_string());
notification_attrs.insert("id".to_string(), "NOTIF-1".to_string());
let notification_node = Node::new(
"notification",
notification_attrs,
Some(NodeContent::String("test".into())),
);
assert!(
client.should_ack(&receipt_node.as_node_ref()),
"should_ack must still return TRUE for <receipt> stanzas."
);
assert!(
client.should_ack(¬ification_node.as_node_ref()),
"should_ack must still return TRUE for <notification> stanzas."
);
let mut dm_attrs = Attrs::new();
dm_attrs.insert(
"from".to_string(),
"5511999999999@s.whatsapp.net".to_string(),
);
dm_attrs.insert("id".to_string(), "MSG-DM-1".to_string());
let dm_message = Node::new("message", dm_attrs, None);
assert!(
!client.should_ack(&dm_message.as_node_ref()),
"should_ack must return FALSE for regular DM <message> (delivery receipt covers it)."
);
let mut group_attrs = Attrs::new();
group_attrs.insert("from".to_string(), "120363098765432100@g.us".to_string());
group_attrs.insert("id".to_string(), "MSG-GROUP-1".to_string());
let group_message = Node::new("message", group_attrs, None);
assert!(
!client.should_ack(&group_message.as_node_ref()),
"should_ack must return FALSE for group <message>."
);
let mut newsletter_attrs = Attrs::new();
newsletter_attrs.insert(
"from".to_string(),
"120363298765432100@newsletter".to_string(),
);
newsletter_attrs.insert("id".to_string(), "MSG-NL-1".to_string());
let newsletter_message = Node::new("message", newsletter_attrs, None);
assert!(
client.should_ack(&newsletter_message.as_node_ref()),
"should_ack must return TRUE for newsletter <message>."
);
let mut status_attrs = Attrs::new();
status_attrs.insert("from".to_string(), "status@broadcast".to_string());
status_attrs.insert("id".to_string(), "MSG-STATUS-1".to_string());
let status_message = Node::new("message", status_attrs, None);
assert!(
client.should_ack(&status_message.as_node_ref()),
"should_ack must return TRUE for status@broadcast <message> (fallback for drop paths)."
);
let mut status_stanza_attrs = Attrs::new();
status_stanza_attrs.insert("from".to_string(), "status@broadcast".to_string());
status_stanza_attrs.insert("id".to_string(), "STATUS-STANZA-1".to_string());
status_stanza_attrs.insert("participant".to_string(), "200725430796339@lid".to_string());
status_stanza_attrs.insert("type".to_string(), "media".to_string());
let status_stanza = Node::new("status", status_stanza_attrs, None);
assert!(
client.should_ack(&status_stanza.as_node_ref()),
"should_ack must return TRUE for a top-level <status> stanza."
);
info!(
"✅ test_ack_behavior_for_incoming_stanzas passed: Client correctly differentiates which stanzas to acknowledge."
);
}
#[tokio::test]
async fn test_ack_waiter_resolves() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let test_id = "ack-test-123".to_string();
let (tx, rx) = oneshot::channel();
client
.response_waiters_guard()
.insert(test_id.clone(), ResponseWaiter::Iq(tx));
assert!(
client.response_waiters_guard().contains_key(&test_id),
"Waiter should be inserted before handling ack"
);
let ack_node = NodeBuilder::new("ack")
.attr("id", test_id.clone())
.attr("from", SERVER_JID)
.build();
let handled = client.handle_ack_response_arc(&Arc::new(to_owned_node(&ack_node)));
assert!(
handled,
"handle_ack_response should return true when waiter exists"
);
match tokio::time::timeout(Duration::from_secs(1), rx).await {
Ok(Ok(response_node)) => {
assert!(
response_node
.get()
.get_attr("id")
.is_some_and(|v| v.as_str() == test_id.as_str()),
"Response node should have correct ID"
);
}
Ok(Err(_)) => panic!("Receiver was dropped without being sent a value"),
Err(_) => panic!("Test timed out waiting for ack response"),
}
assert!(
!client.response_waiters_guard().contains_key(&test_id),
"Waiter should be removed after handling"
);
info!("✅ test_ack_waiter_resolves passed: ACK response correctly resolves pending waiters");
}
#[tokio::test]
async fn test_ack_without_matching_waiter() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ack_node = NodeBuilder::new("ack")
.attr("id", "non-existent-id")
.attr("from", SERVER_JID)
.build();
let handled = client.handle_ack_response_arc(&Arc::new(to_owned_node(&ack_node)));
assert!(
!handled,
"handle_ack_response should return false when no waiter exists"
);
info!(
"✅ test_ack_without_matching_waiter passed: ACK without matching waiter handled gracefully"
);
}
fn to_owned_node(node: &Node) -> wacore_binary::OwnedNodeRef {
wacore_binary::marshal::marshal_ref(&node.as_node_ref())
.and_then(|buf| wacore_binary::OwnedNodeRef::new(bytes::Bytes::from(buf).slice(1..)))
.expect("valid node")
}
fn owned_ack_node(id: &str) -> wacore_binary::OwnedNodeRef {
to_owned_node(
&NodeBuilder::new("ack")
.attr("id", id)
.attr("from", SERVER_JID)
.build(),
)
}
#[tokio::test]
async fn ack_arc_delivery_shares_allocation() {
let client = crate::test_utils::create_test_client().await;
let test_id = "ack-arc-456";
let (tx, rx) = oneshot::channel();
client
.response_waiters_guard()
.insert(test_id.to_string(), ResponseWaiter::Iq(tx));
let node = Arc::new(owned_ack_node(test_id));
assert!(client.handle_ack_response_arc(&node));
let received = tokio::time::timeout(Duration::from_secs(1), rx)
.await
.expect("waiter should resolve")
.expect("sender must not drop");
assert!(
Arc::ptr_eq(&received, &node),
"waiter must receive the original allocation, not a re-encoded copy"
);
assert!(!client.handle_ack_response_arc(&Arc::new(owned_ack_node("ack-arc-none"))));
}
#[tokio::test]
async fn ack_owned_delivery_resolves_waiter() {
let client = crate::test_utils::create_test_client().await;
let test_id = "ack-owned-789";
let (tx, rx) = oneshot::channel();
client
.response_waiters_guard()
.insert(test_id.to_string(), ResponseWaiter::Iq(tx));
assert!(client.handle_ack_response_owned(owned_ack_node(test_id)));
let received = tokio::time::timeout(Duration::from_secs(1), rx)
.await
.expect("waiter should resolve")
.expect("sender must not drop");
assert!(
received
.get()
.get_attr("id")
.is_some_and(|v| v.as_str() == test_id),
"delivered node must carry the ack id"
);
assert!(!client.handle_ack_response_owned(owned_ack_node("ack-owned-none")));
}
#[tokio::test]
async fn test_ack_dispatches_server_ack_event() {
use wacore::types::events::{Event, EventHandler};
let client = crate::test_utils::create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client
.subscribe_handler(collector.clone() as Arc<dyn EventHandler>)
.detach();
let ack_node = NodeBuilder::new("ack")
.attr("id", "ack-evt-1")
.attr("class", "message")
.attr("from", "123456789@s.whatsapp.net")
.attr("t", "1720000000")
.build();
client.handle_ack_response_arc(&Arc::new(to_owned_node(&ack_node)));
assert!(
collector.events().iter().any(|e| matches!(
e.as_ref(),
Event::ServerAck(ack)
if ack.id == "ack-evt-1"
&& ack.class.as_deref() == Some("message")
&& ack.from.as_ref().is_some_and(|j| j.to_string() == "123456789@s.whatsapp.net")
&& ack.timestamp.is_some_and(|t| t.timestamp() == 1_720_000_000)
&& ack.error.is_none()
)),
"server <ack> should dispatch Event::ServerAck with class/from/t"
);
let nack_node = NodeBuilder::new("ack")
.attr("id", "ack-evt-2")
.attr("error", "479")
.attr("from", SERVER_JID)
.build();
client.handle_ack_response_arc(&Arc::new(to_owned_node(&nack_node)));
assert!(
collector.events().iter().any(|e| matches!(
e.as_ref(),
Event::ServerAck(ack)
if ack.id == "ack-evt-2"
&& ack.class.is_none()
&& ack.timestamp.is_none()
&& ack.error.as_deref() == Some("479")
)),
"server nack should dispatch Event::ServerAck carrying the error code"
);
let anon_ack = NodeBuilder::new("ack").attr("from", SERVER_JID).build();
client.handle_ack_response_arc(&Arc::new(to_owned_node(&anon_ack)));
assert_eq!(
collector
.events()
.iter()
.filter(|e| matches!(e.as_ref(), Event::ServerAck(_)))
.count(),
2,
"an <ack> without an id must not dispatch Event::ServerAck"
);
let (tx, rx) = oneshot::channel();
client
.response_waiters_guard()
.insert("ack-evt-3".to_string(), ResponseWaiter::Iq(tx));
let waited_ack = NodeBuilder::new("ack")
.attr("id", "ack-evt-3")
.attr("class", "message")
.attr("from", SERVER_JID)
.build();
let handled = client.handle_ack_response_arc(&Arc::new(to_owned_node(&waited_ack)));
assert!(handled, "waiter for the id should have been resolved");
let resolved = tokio::time::timeout(Duration::from_secs(1), rx)
.await
.expect("timed out waiting for ack waiter")
.expect("waiter sender was dropped");
assert!(
resolved
.get()
.get_attr("id")
.is_some_and(|v| v.as_str() == "ack-evt-3"),
"waiter should receive the ack node"
);
assert!(
collector.events().iter().any(|e| matches!(
e.as_ref(),
Event::ServerAck(ack) if ack.id == "ack-evt-3"
)),
"Event::ServerAck should fire even when a waiter consumes the ack"
);
}
#[tokio::test]
async fn test_lid_pn_cache_basic_operations() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_lid_cache_basic?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let phone = "559980000001";
let lid = "100000012345678";
assert!(
client.lid_pn_cache.get_current_lid(phone).await.is_none(),
"Cache should be empty initially"
);
client
.add_lid_pn_mapping(lid, phone, LearningSource::Usync)
.await
.expect("Failed to persist LID-PN mapping in tests");
let cached_lid = client.lid_pn_cache.get_current_lid(phone).await;
assert!(cached_lid.is_some(), "Cache should contain the mapping");
assert_eq!(
cached_lid.expect("cache should have LID"),
lid,
"Cached LID should match what we inserted"
);
let cached_phone = client.lid_pn_cache.get_phone_number(lid).await;
assert!(cached_phone.is_some(), "Reverse lookup should work");
assert_eq!(
cached_phone.expect("reverse lookup should return phone"),
phone,
"Cached phone should match what we inserted"
);
assert!(
client
.lid_pn_cache
.get_current_lid("559980000002")
.await
.is_none(),
"Different phone number should not have a mapping"
);
info!("✅ test_lid_pn_cache_basic_operations passed: LID-PN cache works correctly");
}
#[tokio::test]
async fn test_lid_pn_cache_timestamp_resolution() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_lid_cache_timestamp?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let phone = "559980000001";
let lid_old = "100000012345678";
let lid_new = "100000087654321";
client
.add_lid_pn_mapping(lid_old, phone, LearningSource::Usync)
.await
.expect("Failed to persist LID-PN mapping in tests");
assert_eq!(
client
.lid_pn_cache
.get_current_lid(phone)
.await
.expect("cache should have LID"),
lid_old,
"Initial LID should be stored"
);
tokio::time::sleep(Duration::from_millis(10)).await;
client
.add_lid_pn_mapping(lid_new, phone, LearningSource::PeerPnMessage)
.await
.expect("Failed to persist LID-PN mapping in tests");
assert_eq!(
client
.lid_pn_cache
.get_current_lid(phone)
.await
.expect("cache should have newer LID"),
lid_new,
"Newer LID should be returned for phone lookup"
);
assert_eq!(
client
.lid_pn_cache
.get_phone_number(lid_old)
.await
.expect("reverse lookup should return phone"),
phone,
"Old LID should still map to phone"
);
assert_eq!(
client
.lid_pn_cache
.get_phone_number(lid_new)
.await
.expect("reverse lookup should return phone"),
phone,
"New LID should also map to phone"
);
info!(
"✅ test_lid_pn_cache_timestamp_resolution passed: Timestamp-based resolution works correctly"
);
}
#[tokio::test]
async fn test_get_lid_for_phone_via_send_context_resolver() {
use wacore::client::context::SendContextResolver;
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_get_lid_for_phone?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let phone = "559980000001";
let lid = "100000012345678";
assert!(
client.get_lid_for_phone(phone).await.is_none(),
"get_lid_for_phone should return None before caching"
);
client
.add_lid_pn_mapping(lid, phone, LearningSource::Usync)
.await
.expect("Failed to persist LID-PN mapping in tests");
let result = client.get_lid_for_phone(phone).await;
assert!(
result.is_some(),
"get_lid_for_phone should return Some after caching"
);
assert_eq!(
result.expect("get_lid_for_phone should return Some"),
lid,
"get_lid_for_phone should return the cached LID"
);
info!(
"✅ test_get_lid_for_phone_via_send_context_resolver passed: SendContextResolver correctly returns cached LID"
);
}
#[tokio::test]
async fn test_wait_for_offline_delivery_end_returns_immediately_when_flag_set() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_offline_sync_flag_set?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
client.offline_sync_completed.store(true, Ordering::Relaxed);
let start = wacore::time::Instant::now();
client.wait_for_offline_delivery_end().await;
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 100,
"wait_for_offline_delivery_end should return immediately when flag is set, took {:?}",
elapsed
);
info!("✅ test_wait_for_offline_delivery_end_returns_immediately_when_flag_set passed");
}
#[tokio::test]
async fn test_wait_for_offline_delivery_end_times_out_when_flag_not_set() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_offline_sync_timeout?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let start = wacore::time::Instant::now();
client
.wait_for_offline_delivery_end_with_timeout(Duration::from_millis(50))
.await;
let elapsed = start.elapsed();
let mut permits = 0;
for _ in 0..100 {
let semaphore = match client.message_processing_semaphore.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
let mut guards = Vec::new();
while let Some(guard) = semaphore.try_acquire() {
guards.push(guard);
}
permits = guards.len();
drop(guards);
if permits == 64 {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
elapsed.as_millis() >= 45, "Should have waited for the configured timeout duration, took {:?}",
elapsed
);
assert!(
client.offline_sync_completed.load(Ordering::Relaxed),
"wait_for_offline_delivery_end should mark offline sync complete on timeout"
);
assert_eq!(
permits, 64,
"timeout completion should restore parallel permits"
);
info!("✅ test_wait_for_offline_delivery_end_times_out_when_flag_not_set passed");
}
#[tokio::test]
async fn test_wait_for_offline_delivery_end_returns_on_notify() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_offline_notify?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let client_clone = client.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
client_clone.offline_sync_notifier.notify(usize::MAX);
});
let start = wacore::time::Instant::now();
client.wait_for_offline_delivery_end().await;
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 200,
"wait_for_offline_delivery_end should return when notified, took {:?}",
elapsed
);
assert!(
elapsed.as_millis() >= 45, "Should have waited for the notify, only took {:?}",
elapsed
);
info!("✅ test_wait_for_offline_delivery_end_returns_on_notify passed");
}
#[tokio::test]
async fn test_offline_sync_flag_initially_false() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_offline_flag_initial?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert!(
!client.offline_sync_completed.load(Ordering::Relaxed),
"offline_sync_completed should be false when Client is first created"
);
info!("✅ test_offline_sync_flag_initially_false passed");
}
#[tokio::test]
async fn test_offline_sync_lifecycle() {
use std::sync::atomic::Ordering;
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_offline_lifecycle?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert!(!client.offline_sync_completed.load(Ordering::Relaxed));
let client_waiter = client.clone();
let waiter_handle = tokio::spawn(async move {
client_waiter.wait_for_offline_delivery_end().await;
true });
crate::test_utils::wait_for_notifier_listeners(&client.offline_sync_notifier, 1).await;
assert!(
!waiter_handle.is_finished(),
"Waiter should still be waiting"
);
client.offline_sync_completed.store(true, Ordering::Relaxed);
client.offline_sync_notifier.notify(usize::MAX);
let result = tokio::time::timeout(Duration::from_millis(100), waiter_handle)
.await
.expect("Waiter should complete after notify")
.expect("Waiter task should not panic");
assert!(result, "Waiter should have completed successfully");
assert!(client.offline_sync_completed.load(Ordering::Relaxed));
info!("✅ test_offline_sync_lifecycle passed");
}
#[tokio::test]
async fn test_establish_primary_phone_session_fails_without_pn() {
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_no_pn?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let result = client.establish_primary_phone_session_immediate().await;
assert!(
result.is_err(),
"establish_primary_phone_session_immediate should fail when no PN is set"
);
let err = result.unwrap_err();
assert!(
err.downcast_ref::<ClientError>()
.is_some_and(|e| matches!(e, ClientError::NotLoggedIn)),
"Error should be ClientError::NotLoggedIn, got: {}",
err
);
info!("✅ test_establish_primary_phone_session_fails_without_pn passed");
}
#[tokio::test]
async fn test_ensure_e2e_sessions_waits_for_offline_sync() {
use std::sync::atomic::Ordering;
use wacore_binary::Jid;
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_ensure_e2e_waits?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert!(!client.offline_sync_completed.load(Ordering::Relaxed));
let client_clone = client.clone();
let ensure_handle = tokio::spawn(async move {
client_clone.ensure_e2e_sessions(&[]).await
});
tokio::time::timeout(Duration::from_secs(5), ensure_handle)
.await
.expect("ensure_e2e_sessions should return immediately for empty JID list")
.expect("ensure_e2e_sessions task should not panic")
.expect("empty JID list should succeed");
let client_clone = client.clone();
let test_jid = Jid::pn("559999999999");
let ensure_handle = tokio::spawn(async move {
let start = wacore::time::Instant::now();
let _ = client_clone.ensure_e2e_sessions(&[test_jid]).await;
start.elapsed()
});
crate::test_utils::wait_for_notifier_listeners(&client.offline_sync_notifier, 1).await;
assert!(
!ensure_handle.is_finished(),
"ensure_e2e_sessions should be waiting for offline sync"
);
client.offline_sync_completed.store(true, Ordering::Relaxed);
client.offline_sync_notifier.notify(usize::MAX);
let result = tokio::time::timeout(Duration::from_secs(2), ensure_handle).await;
assert!(
result.is_ok(),
"ensure_e2e_sessions should complete after offline sync"
);
info!("✅ test_ensure_e2e_sessions_waits_for_offline_sync passed");
}
#[tokio::test]
async fn ensure_sessions_warm_cache_short_circuits() {
use wacore::types::jid::JidExt;
let client = crate::test_utils::create_test_client().await;
let jid: Jid = "15550005555@s.whatsapp.net".parse().unwrap();
assert!(
client
.ensure_e2e_sessions_resolved(std::slice::from_ref(&jid))
.await
.is_err(),
"unknown session must still attempt the fetch"
);
assert!(
client
.signal_cache
.try_put_session(
&jid.to_protocol_address(),
wacore::libsignal::protocol::SessionRecord::new_fresh(),
)
.is_ok()
);
client
.ensure_e2e_sessions_resolved(&[jid])
.await
.expect("cached session must satisfy ensure without network");
}
#[tokio::test]
async fn test_immediate_session_does_not_wait_for_offline_sync() {
use std::sync::atomic::Ordering;
use wacore_binary::Jid;
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_immediate_no_wait?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend.clone())
.await
.expect("persistence manager should initialize"),
);
pm.modify_device(|device| {
device.pn = Some(Jid::pn("559999999999"));
})
.await;
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert!(!client.offline_sync_completed.load(Ordering::Relaxed));
let start = wacore::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_millis(500),
client.establish_primary_phone_session_immediate(),
)
.await;
let elapsed = start.elapsed();
assert!(
result.is_ok(),
"establish_primary_phone_session_immediate should not wait for offline sync, timed out"
);
assert!(
elapsed.as_millis() < 500,
"establish_primary_phone_session_immediate should not wait, took {:?}",
elapsed
);
info!(
"establish_primary_phone_session_immediate completed in {:?} (result: {:?})",
elapsed,
result.unwrap().is_ok()
);
info!("✅ test_immediate_session_does_not_wait_for_offline_sync passed");
}
#[tokio::test]
async fn test_establish_session_skips_when_exists() {
use wacore::libsignal::protocol::SessionRecord;
use wacore::libsignal::store::SessionStore;
use wacore::types::jid::JidExt;
use wacore_binary::Jid;
let backend = Arc::new(
crate::store::SqliteStore::new("file:memdb_skip_existing?mode=memory&cache=shared")
.await
.expect("Failed to create in-memory backend for test"),
);
let pm = Arc::new(
PersistenceManager::new(backend.clone())
.await
.expect("persistence manager should initialize"),
);
let own_pn = Jid::pn("559999999999");
pm.modify_device(|device| {
device.pn = Some(own_pn.clone());
})
.await;
let primary_phone_jid = own_pn.with_device(0);
let signal_addr = primary_phone_jid.to_protocol_address();
let dummy_session = SessionRecord::new_fresh();
{
let device_arc = pm.get_device_arc().await;
let device = device_arc.read().await;
device
.store_session(&signal_addr, &dummy_session)
.await
.expect("Failed to store test session");
let exists = device
.contains_session(&signal_addr)
.await
.expect("Failed to check session");
assert!(exists, "Session should exist after store");
}
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm.clone(),
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let result = client.establish_primary_phone_session_immediate().await;
assert!(
result.is_ok(),
"establish_primary_phone_session_immediate should succeed when session exists"
);
{
let device_arc = pm.get_device_arc().await;
let device = device_arc.read().await;
let exists = device
.contains_session(&signal_addr)
.await
.expect("Failed to check session");
assert!(exists, "Session should still exist after the call");
}
info!("✅ test_establish_session_skips_when_exists passed");
}
#[test]
fn test_mac_failure_prevention_flow_documentation() {
fn should_establish_session(check_result: Result<bool, &'static str>) -> Result<bool, String> {
match check_result {
Ok(true) => Ok(false), Ok(false) => Ok(true), Err(e) => Err(format!("Cannot verify session: {}", e)), }
}
let result = should_establish_session(Ok(true));
assert_eq!(result, Ok(false), "Should skip when session exists");
let result = should_establish_session(Ok(false));
assert_eq!(result, Ok(true), "Should establish when no session");
let result = should_establish_session(Err("database error"));
assert!(result.is_err(), "Should fail when check fails");
info!("✅ test_mac_failure_prevention_flow_documentation passed");
}
#[test]
fn test_unified_session_id_calculation() {
const DAY_MS: i64 = 24 * 60 * 60 * 1000;
const WEEK_MS: i64 = 7 * DAY_MS;
const OFFSET_MS: i64 = 3 * DAY_MS;
fn calculate_session_id(now_ms: i64, server_offset_ms: i64) -> i64 {
let adjusted_now = now_ms + server_offset_ms;
(adjusted_now + OFFSET_MS) % WEEK_MS
}
let now_ms = 1706000000000_i64; let id = calculate_session_id(now_ms, 0);
assert!(
(0..WEEK_MS).contains(&id),
"Session ID should be in [0, WEEK_MS)"
);
let id_with_positive_offset = calculate_session_id(now_ms, 5000);
assert!(
(0..WEEK_MS).contains(&id_with_positive_offset),
"Session ID should be in [0, WEEK_MS)"
);
let id_with_negative_offset = calculate_session_id(now_ms, -5000);
assert!(
(0..WEEK_MS).contains(&id_with_negative_offset),
"Session ID should be in [0, WEEK_MS)"
);
let wrap_test_now = WEEK_MS - OFFSET_MS + 1000; let wrapped_id = calculate_session_id(wrap_test_now, 0);
assert_eq!(wrapped_id, 1000, "Should wrap around correctly");
let boundary_now = WEEK_MS - OFFSET_MS;
let boundary_id = calculate_session_id(boundary_now, 0);
assert_eq!(boundary_id, 0, "At exact boundary should be 0");
}
#[tokio::test]
async fn test_server_time_offset_extraction() {
use wacore_binary::builder::NodeBuilder;
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert_eq!(
client.unified_session.server_time_offset_ms(),
0,
"Initial offset should be 0"
);
let server_time = wacore::time::now_secs() + 10; let node = NodeBuilder::new("success").attr("t", server_time).build();
client.update_server_time_offset(&node.as_node_ref());
let offset = client.unified_session.server_time_offset_ms();
assert!(
(offset - 10000).abs() < 1000, "Offset should be approximately 10000ms, got {}",
offset
);
let node_no_t = NodeBuilder::new("success").build();
client.update_server_time_offset(&node_no_t.as_node_ref());
let offset_after = client.unified_session.server_time_offset_ms();
assert!(
(offset_after - offset).abs() < 100, "Offset should not change when 't' is missing"
);
let node_invalid = NodeBuilder::new("success")
.attr("t", "not_a_number")
.build();
client.update_server_time_offset(&node_invalid.as_node_ref());
let offset_after_invalid = client.unified_session.server_time_offset_ms();
assert!(
(offset_after_invalid - offset).abs() < 100,
"Offset should not change when 't' is invalid"
);
let node_zero = NodeBuilder::new("success").attr("t", "0").build();
client.update_server_time_offset(&node_zero.as_node_ref());
let offset_after_zero = client.unified_session.server_time_offset_ms();
assert!(
(offset_after_zero - offset).abs() < 100,
"Offset should not change when 't' is 0"
);
info!("✅ test_server_time_offset_extraction passed");
}
#[tokio::test]
async fn test_unified_session_manager_integration() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert_eq!(
client.unified_session.sequence(),
0,
"Initial sequence should be 0"
);
loop {
client.unified_session.reset().await;
let result = client.unified_session.prepare_send().await;
assert!(result.is_some(), "First send should succeed");
let (node, seq) = result.unwrap();
assert_eq!(node.tag, "ib", "Should be an IB stanza");
assert_eq!(seq, 1, "First sequence should be 1 (pre-increment)");
assert_eq!(client.unified_session.sequence(), 1);
let result2 = client.unified_session.prepare_send().await;
if result2.is_none() {
assert_eq!(client.unified_session.sequence(), 1);
break;
}
tokio::task::yield_now().await;
}
client.unified_session.clear_last_sent().await;
let result3 = client.unified_session.prepare_send().await;
assert!(result3.is_some(), "Should succeed after clearing");
let (_, seq3) = result3.unwrap();
assert_eq!(seq3, 1, "Sequence resets when session ID changes");
assert_eq!(client.unified_session.sequence(), 1);
info!("✅ test_unified_session_manager_integration passed");
}
#[test]
fn test_unified_session_protocol_node() {
use wacore::ib::{IbStanza, UnifiedSession};
use wacore::protocol::ProtocolNode;
let session = UnifiedSession::new("123456789");
assert_eq!(session.id, "123456789");
assert_eq!(session.tag(), "unified_session");
let node = session.into_node();
assert_eq!(node.tag, "unified_session");
assert!(node.attrs.get("id").is_some_and(|v| v == "123456789"));
let stanza = IbStanza::unified_session(UnifiedSession::new("987654321"));
assert_eq!(stanza.tag(), "ib");
let ib_node = stanza.into_node();
assert_eq!(ib_node.tag, "ib");
let children = ib_node.children().expect("IB stanza should have children");
assert_eq!(children.len(), 1);
assert_eq!(children[0].tag, "unified_session");
assert!(
children[0]
.attrs
.get("id")
.is_some_and(|v| v == "987654321")
);
info!("✅ test_unified_session_protocol_node passed");
}
fn node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef> {
crate::test_utils::node_to_owned_ref(&node)
}
async fn create_offline_sync_test_client() -> Arc<Client> {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
client
}
#[tokio::test]
async fn cleanup_connection_state_flushes_dirty_signal_state() {
use wacore::libsignal::protocol::ProtocolAddress;
let client = create_offline_sync_test_client().await;
let addr = ProtocolAddress::new("5550001000@s.whatsapp.net", 1u32.into());
client.signal_cache.put_identity(&addr, &[7u8; 32]).await;
client.cleanup_connection_state().await;
let device = client.persistence_manager.get_device_arc().await;
let guard = device.read().await;
let persisted = client
.signal_cache
.get_identity(&addr, &*guard.backend)
.await
.expect("get_identity must not error");
assert!(
persisted.is_some(),
"dirty Signal state must survive a transport disconnect (flush-before-clear)"
);
}
#[tokio::test]
async fn cleanup_connection_state_flushes_dirty_sender_key() {
use wacore::libsignal::protocol::SenderKeyRecord;
use wacore::libsignal::store::sender_key_name::SenderKeyName;
let client = create_offline_sync_test_client().await;
let name = SenderKeyName::from_parts("group@g.us", "5550001000@s.whatsapp.net:1");
client
.signal_cache
.put_sender_key(&name, SenderKeyRecord::new_empty())
.await;
client.cleanup_connection_state().await;
let device = client.persistence_manager.get_device_arc().await;
let guard = device.read().await;
let persisted = client
.signal_cache
.get_sender_key(&name, &*guard.backend)
.await
.expect("get_sender_key must not error");
assert!(
persisted.is_some(),
"dirty sender key must survive a transport disconnect (flush-before-clear)"
);
}
#[tokio::test]
async fn cleanup_connection_state_does_not_burn_a_clean_sender_key_lease() {
use wacore::libsignal::protocol::{KeyPair, SenderKeyRecord};
use wacore::libsignal::store::sender_key_name::SenderKeyName;
let client = create_offline_sync_test_client().await;
let name = SenderKeyName::from_parts("group@g.us", "5550001001@s.whatsapp.net:1");
let mut rng = rand::make_rng::<rand::rngs::StdRng>();
let signing_key = KeyPair::generate(&mut rng);
let mut record = SenderKeyRecord::new_empty();
record
.add_sender_key_state(
3,
12345,
0,
&[0x42; 32],
signing_key.public_key,
Some(signing_key.private_key),
)
.expect("sender key state");
record.reserve_iterations(0);
client.signal_cache.put_sender_key(&name, record).await;
client.cleanup_connection_state().await;
let device = client.persistence_manager.get_device_arc().await;
let guard = device.read().await;
let reloaded = client
.signal_cache
.get_sender_key(&name, &*guard.backend)
.await
.expect("sender key load")
.expect("sender key");
assert_eq!(
reloaded
.sender_key_state()
.expect("sender key state")
.sender_chain_key()
.expect("sender chain")
.iteration(),
0
);
}
#[tokio::test]
async fn cleanup_connection_state_keeps_state_when_flush_fails() {
use wacore::libsignal::protocol::{ProtocolAddress, SenderKeyRecord};
use wacore::libsignal::store::sender_key_name::SenderKeyName;
let client = create_offline_sync_test_client().await;
let bad = ProtocolAddress::new("5550002000@s.whatsapp.net", 1u32.into());
client.signal_cache.put_identity(&bad, &[0u8; 16]).await;
let name = SenderKeyName::from_parts("group@g.us", "5550001000@s.whatsapp.net:1");
client
.signal_cache
.put_sender_key(&name, SenderKeyRecord::new_empty())
.await;
client.cleanup_connection_state().await;
let device = client.persistence_manager.get_device_arc().await;
let guard = device.read().await;
let persisted = client
.signal_cache
.get_sender_key(&name, &*guard.backend)
.await
.expect("get_sender_key must not error");
assert!(
persisted.is_some(),
"a flush failure must not drop dirty Signal state"
);
}
#[tokio::test]
async fn connect_failure_403_dispatches_account_locked_logout() {
use wacore::types::events::ChannelEventHandler;
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "403")
.attr("location", "rva")
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
let evt = events
.try_recv()
.expect("403 must dispatch a LoggedOut event");
match &*evt {
Event::LoggedOut(lo) => {
assert!(lo.on_connect, "403 arrives as a failure-on-connect");
assert_eq!(lo.reason, ConnectFailureReason::AccountLocked);
}
_ => panic!("expected Event::LoggedOut for reason=403"),
}
assert!(
!client.enable_auto_reconnect.load(Ordering::Relaxed),
"a server-side lock must not auto-reconnect"
);
}
#[tokio::test]
async fn account_lock_logout_preserves_enforcement_attributes() {
use wacore::types::events::ChannelEventHandler;
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "403")
.attr("location", "rva")
.attr("violation_reason", "other_harm")
.attr("vt", "1")
.attr("appeal_token", "0aFICTITIOUSappealTOKEN00")
.attr("logout_message_header", "Conta desconectada")
.attr("logout_message_subtext", "Abra o WhatsApp no celular")
.attr("logout_message_locale", "pt_BR")
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
let evt = events.try_recv().expect("403 dispatches LoggedOut");
match &*evt {
Event::LoggedOut(lo) => {
let raw = lo.raw.as_ref().expect("the <failure> stanza must survive");
assert_eq!(
raw.attrs.get("appeal_token").map(|v| v.as_str()).as_deref(),
Some("0aFICTITIOUSappealTOKEN00"),
"the one-time appeal token must reach the embedder"
);
assert_eq!(
raw.attrs
.get("violation_reason")
.map(|v| v.as_str())
.as_deref(),
Some("other_harm")
);
assert_eq!(
raw.attrs.get("vt").map(|v| v.as_str()).as_deref(),
Some("1")
);
let msg = lo
.logout_message
.as_ref()
.expect("logout_message_* must be surfaced");
assert_eq!(msg.header.as_deref(), Some("Conta desconectada"));
assert_eq!(msg.subtext.as_deref(), Some("Abra o WhatsApp no celular"));
assert_eq!(msg.locale.as_deref(), Some("pt_BR"));
}
_ => panic!("expected Event::LoggedOut for reason=403"),
}
}
#[tokio::test]
async fn temporary_ban_carries_message_url_and_stanza() {
use wacore::types::events::{ChannelEventHandler, TempBanReason};
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "402")
.attr("code", "101")
.attr("expire", "3600")
.attr("message", "too many messages")
.attr("url", "https://faq.example.invalid/ban")
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
match &*events.try_recv().expect("402 dispatches TemporaryBan") {
Event::TemporaryBan(ban) => {
assert_eq!(ban.code, TempBanReason::SentToTooManyPeople);
assert_eq!(ban.expire, chrono::Duration::seconds(3600));
assert_eq!(ban.message.as_deref(), Some("too many messages"));
assert_eq!(ban.url.as_deref(), Some("https://faq.example.invalid/ban"));
assert!(ban.raw.is_some(), "the <failure> stanza must survive");
}
_ => panic!("expected Event::TemporaryBan for reason=402"),
}
}
#[tokio::test]
async fn temporary_ban_without_expire_falls_back_to_connect_failure() {
use wacore::types::events::ChannelEventHandler;
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "402")
.attr("code", "101")
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
match &*events
.try_recv()
.expect("an incomplete 402 still dispatches")
{
Event::ConnectFailure(cf) => {
assert_eq!(cf.reason, ConnectFailureReason::TempBanned);
assert!(cf.raw.is_some(), "the <failure> stanza must survive");
}
other => panic!("expected Event::ConnectFailure, got {other:?}"),
}
}
#[tokio::test]
async fn temporary_ban_with_unrepresentable_expire_falls_back_to_connect_failure() {
use wacore::types::events::ChannelEventHandler;
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "402")
.attr("code", "101")
.attr("expire", u64::MAX.to_string())
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
match &*events.try_recv().expect("a garbage 402 still dispatches") {
Event::ConnectFailure(cf) => {
assert_eq!(cf.reason, ConnectFailureReason::TempBanned);
assert!(cf.raw.is_some(), "the <failure> stanza must survive");
}
other => panic!("expected Event::ConnectFailure, got {other:?}"),
}
}
#[tokio::test]
async fn client_outdated_carries_the_stanza() {
use wacore::types::events::ChannelEventHandler;
let client = create_offline_sync_test_client().await;
let (handler, events) = ChannelEventHandler::new();
client.subscribe_handler(handler).detach();
let failure = NodeBuilder::new("failure")
.attr("reason", "405")
.attr("message", "client too old")
.build();
client.handle_connect_failure(&failure.as_node_ref()).await;
match &*events.try_recv().expect("405 dispatches ClientOutdated") {
Event::ClientOutdated(co) => {
assert!(co.raw.is_some(), "the <failure> stanza must survive")
}
_ => panic!("expected Event::ClientOutdated for reason=405"),
}
}
#[tokio::test]
async fn delivery_receipt_activity_state_machine() {
let client = create_offline_sync_test_client().await;
assert!(
!client.receipts_are_active(),
"default is inactive (background companion)"
);
client.mark_receipts_active_on_presence();
assert!(client.receipts_are_active(), "presence available -> active");
client.mark_receipts_inactive_on_presence();
assert!(
!client.receipts_are_active(),
"presence unavailable -> inactive"
);
client.set_force_active_delivery_receipts(true);
assert!(client.receipts_are_active(), "forced active");
client.mark_receipts_inactive_on_presence();
assert!(
client.receipts_are_active(),
"forced (2) survives a presence-unavailable CAS(1,0)"
);
client.set_force_active_delivery_receipts(false);
assert!(!client.receipts_are_active());
client.mark_receipts_active_on_presence();
client.cleanup_connection_state().await;
assert!(
!client.receipts_are_active(),
"teardown resets presence-driven active"
);
client.set_force_active_delivery_receipts(true);
client.cleanup_connection_state().await;
assert!(
client.receipts_are_active(),
"teardown preserves forced active"
);
}
#[tokio::test]
async fn test_ib_thread_metadata_does_not_end_sync() {
let client = create_offline_sync_test_client().await;
client
.offline_sync_metrics
.active
.store(true, Ordering::Release);
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("thread_metadata")
.children([NodeBuilder::new("item").build()])
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
assert!(
client.offline_sync_metrics.active.load(Ordering::Acquire),
"<ib><thread_metadata> should NOT end offline sync"
);
}
#[tokio::test]
async fn test_ib_edge_routing_does_not_end_sync() {
let client = create_offline_sync_test_client().await;
client
.offline_sync_metrics
.active
.store(true, Ordering::Release);
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("edge_routing")
.children([NodeBuilder::new("routing_info")
.bytes(vec![1, 2, 3])
.build()])
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
assert!(
client.offline_sync_metrics.active.load(Ordering::Acquire),
"<ib><edge_routing> should NOT end offline sync"
);
}
#[tokio::test]
async fn test_ib_dirty_does_not_end_sync() {
let client = create_offline_sync_test_client().await;
client
.offline_sync_metrics
.active
.store(true, Ordering::Release);
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("dirty")
.attr("type", "groups")
.attr("timestamp", "1234")
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
assert!(
client.offline_sync_metrics.active.load(Ordering::Acquire),
"<ib><dirty> should NOT end offline sync"
);
}
#[tokio::test]
async fn test_ib_offline_child_ends_sync() {
let client = create_offline_sync_test_client().await;
client
.offline_sync_metrics
.active
.store(true, Ordering::Release);
client
.offline_sync_metrics
.total_messages
.store(301, Ordering::Release);
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("offline").attr("count", "301").build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
assert!(
!client.offline_sync_metrics.active.load(Ordering::Acquire),
"<ib><offline count='301'/> should end offline sync"
);
}
#[tokio::test]
async fn test_ib_offline_preview_starts_sync() {
let client = create_offline_sync_test_client().await;
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("offline_preview")
.attr("count", "301")
.attr("message", "168")
.attr("notification", "62")
.attr("receipt", "68")
.attr("appdata", "0")
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
assert!(
client.offline_sync_metrics.active.load(Ordering::Acquire),
"offline_preview with count>0 should activate sync"
);
assert_eq!(
client
.offline_sync_metrics
.total_messages
.load(Ordering::Acquire),
301
);
}
#[tokio::test]
async fn test_offline_message_increments_processed() {
let client = create_offline_sync_test_client().await;
client
.offline_sync_metrics
.active
.store(true, Ordering::Release);
client
.offline_sync_metrics
.total_messages
.store(100, Ordering::Release);
let node = NodeBuilder::new("message")
.attr("offline", "1")
.attr("from", "5551234567@s.whatsapp.net")
.attr("id", "TEST123")
.attr("t", "1772884671")
.attr("type", "text")
.build();
client.process_node(node_to_owned_ref(node)).await;
assert_eq!(
client
.offline_sync_metrics
.processed_messages
.load(Ordering::Acquire),
1,
"offline message should increment processed count"
);
}
#[tokio::test]
async fn test_handle_iq_ping_with_child_element() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ping_node = NodeBuilder::new("iq")
.attr("type", "get")
.attr("from", SERVER_JID)
.attr("id", "ping-child-1")
.children([NodeBuilder::new("ping").build()])
.build();
let handled = client.handle_iq(&ping_node.as_node_ref()).await;
assert!(
handled,
"handle_iq must recognize ping with <ping> child element"
);
}
#[tokio::test]
async fn test_handle_iq_ping_with_xmlns_attribute() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ping_node = NodeBuilder::new("iq")
.attr("type", "get")
.attr("from", SERVER_JID)
.attr("id", "ping-xmlns-1")
.attr("xmlns", "urn:xmpp:ping")
.build();
let handled = client.handle_iq(&ping_node.as_node_ref()).await;
assert!(
handled,
"handle_iq must recognize ping with xmlns=\"urn:xmpp:ping\" attribute (no children)"
);
}
#[tokio::test]
async fn test_handle_iq_ping_with_both_child_and_xmlns() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ping_node = NodeBuilder::new("iq")
.attr("type", "get")
.attr("from", SERVER_JID)
.attr("id", "ping-both-1")
.attr("xmlns", "urn:xmpp:ping")
.children([NodeBuilder::new("ping").build()])
.build();
let handled = client.handle_iq(&ping_node.as_node_ref()).await;
assert!(
handled,
"handle_iq must handle ping with both child and xmlns"
);
}
#[tokio::test]
async fn test_handle_iq_ping_without_type_attr() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ping_node = NodeBuilder::new("iq")
.attr("from", SERVER_JID)
.attr("id", "ping-notype-1")
.attr("xmlns", "urn:xmpp:ping")
.build();
let handled = client.handle_iq(&ping_node.as_node_ref()).await;
assert!(
handled,
"handle_iq must pong a urn:xmpp:ping IQ even without a type attribute"
);
}
#[tokio::test]
async fn test_handle_iq_non_ping_returns_false() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let non_ping_node = NodeBuilder::new("iq")
.attr("type", "get")
.attr("from", SERVER_JID)
.attr("id", "not-a-ping")
.attr("xmlns", "some:other:namespace")
.build();
let handled = client.handle_iq(&non_ping_node.as_node_ref()).await;
assert!(
!handled,
"handle_iq must NOT treat non-ping xmlns as a ping"
);
}
#[tokio::test]
async fn test_handle_iq_ping_wrong_type_returns_false() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let result_node = NodeBuilder::new("iq")
.attr("type", "result")
.attr("from", SERVER_JID)
.attr("id", "ping-result-1")
.attr("xmlns", "urn:xmpp:ping")
.build();
let handled = client.handle_iq(&result_node.as_node_ref()).await;
assert!(
!handled,
"handle_iq must NOT respond to type=\"result\" even with ping xmlns"
);
}
#[test]
fn test_build_pong_with_id() {
let pong = build_pong("s.whatsapp.net".to_string(), Some("ping-123"));
assert!(
pong.attrs.get("id").is_some_and(|v| v == "ping-123"),
"pong should include id when server ping has one"
);
assert!(pong.attrs.get("type").is_some_and(|v| v == "result"));
assert!(pong.attrs.get("to").is_some_and(|v| v == "s.whatsapp.net"));
}
#[test]
fn test_build_pong_without_id() {
let pong = build_pong("s.whatsapp.net".to_string(), None);
assert!(
!pong.attrs.contains_key("id"),
"pong should NOT include id when server ping has none"
);
assert!(pong.attrs.get("type").is_some_and(|v| v == "result"));
}
#[test]
fn test_encrypt_identity_notification_omits_type() {
let node = NodeBuilder::new("notification")
.attr("from", "186303081611421@lid")
.attr("id", "4128735301")
.attr("type", "encrypt")
.children([NodeBuilder::new("identity").build()])
.build();
assert!(
is_encrypt_identity_notification(&node.as_node_ref()),
"identity-change notification ACK must omit type to match WA Web"
);
}
#[test]
fn test_device_notification_is_not_encrypt_identity() {
let node = NodeBuilder::new("notification")
.attr("from", "186303081611421@lid")
.attr("id", "269488578")
.attr("type", "devices")
.children([NodeBuilder::new("remove").build()])
.build();
assert!(
!is_encrypt_identity_notification(&node.as_node_ref()),
"device notification is not an encrypt+identity notification"
);
}
#[test]
fn test_build_ack_node_for_message_preserves_type_and_includes_from() {
let incoming = NodeBuilder::new("message")
.attr("from", "120363161500776365@g.us")
.attr("id", "A5791A5392EF60E3FB0670098DE010D4")
.attr("type", "text")
.attr("participant", "181531758878822@lid")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("message ack should be buildable");
assert_eq!(ack.tag, "ack");
assert!(ack.attrs.get("class").is_some_and(|v| v == "message"));
assert!(
ack.attrs
.get("to")
.is_some_and(|v| v == "120363161500776365@g.us")
);
assert!(
ack.attrs
.get("from")
.is_some_and(|v| v == "155500012345:48@s.whatsapp.net")
);
assert!(
ack.attrs
.get("participant")
.is_some_and(|v| v == "181531758878822@lid")
);
assert!(
ack.attrs.get("type").is_some_and(|v| v == "text"),
"message ACK must echo its explicit type"
);
}
#[test]
fn test_build_ack_node_for_identity_change_omits_type_and_from() {
let incoming = NodeBuilder::new("notification")
.attr("from", "186303081611421@lid")
.attr("id", "4128735301")
.attr("type", "encrypt")
.children([NodeBuilder::new("identity").build()])
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("notification ack should be buildable");
assert!(ack.attrs.get("class").is_some_and(|v| v == "notification"));
assert!(
!ack.attrs.contains_key("type"),
"identity-change notification ACK must omit type"
);
assert!(
!ack.attrs.contains_key("from"),
"notification ACKs should not include our device PN"
);
}
#[test]
fn test_build_ack_node_for_receipt_with_type_echoes_type() {
let incoming = NodeBuilder::new("receipt")
.attr("from", "156535032389744@lid")
.attr("id", "RCPT-WITH-TYPE")
.attr("type", "read")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("receipt ack should be buildable");
assert!(ack.attrs.get("class").is_some_and(|v| v == "receipt"));
assert!(
ack.attrs.get("type").is_some_and(|v| v == "read"),
"receipt ACK must echo the type attribute when present"
);
assert!(
!ack.attrs.contains_key("from"),
"receipt ACKs should not include our device PN"
);
}
#[test]
fn test_build_ack_node_drops_participant_when_equal_to_from() {
let incoming = NodeBuilder::new("receipt")
.attr("from", "156535032389744@lid")
.attr("participant", "156535032389744@lid")
.attr("id", "RCPT-PARTICIPANT-EQ-FROM")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net".parse().unwrap();
let ack =
build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn)).expect("ack should build");
assert!(
!ack.attrs.contains_key("participant"),
"ack must drop participant when it duplicates `to` (the flipped from); got {:?}",
ack.attrs.get("participant")
);
}
#[test]
fn test_build_ack_node_keeps_participant_when_distinct_from_from() {
let incoming = NodeBuilder::new("receipt")
.attr("from", "120363098765432100@g.us")
.attr("participant", "5511999999999@s.whatsapp.net")
.attr("id", "RCPT-GROUP")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net".parse().unwrap();
let ack =
build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn)).expect("ack should build");
assert!(
ack.attrs
.get("participant")
.is_some_and(|v| v == "5511999999999@s.whatsapp.net"),
"ack must keep participant when it differs from `to`"
);
}
#[test]
fn test_build_ack_node_for_receipt_without_type_omits_type() {
let incoming = NodeBuilder::new("receipt")
.attr("from", "156535032389744@lid")
.attr("id", "RCPT-NO-TYPE")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("receipt ack should be buildable");
assert!(ack.attrs.get("class").is_some_and(|v| v == "receipt"));
assert!(
!ack.attrs.contains_key("type"),
"receipt ACK must NOT contain type when the incoming receipt has no type attribute"
);
assert!(
!ack.attrs.contains_key("from"),
"receipt ACKs should not include our device PN"
);
}
#[test]
fn test_build_ack_node_for_message_with_recipient_preserves_recipient() {
let incoming = NodeBuilder::new("message")
.attr("from", "166361967902821@lid")
.attr("id", "2A32F960553696093D99")
.attr("type", "text")
.attr("recipient", "146991363395800@lid")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("message ack should be buildable");
assert!(ack.attrs.get("class").is_some_and(|v| v == "message"));
assert!(
ack.attrs
.get("recipient")
.is_some_and(|v| v == "146991363395800@lid"),
"message ACK must echo the incoming `recipient` attribute"
);
}
#[test]
fn test_build_ack_node_for_receipt_with_recipient_preserves_recipient() {
let incoming = NodeBuilder::new("receipt")
.attr("from", "120363098765432100@g.us")
.attr("id", "RCPT-WITH-RECIPIENT")
.attr("type", "read")
.attr("recipient", "242395589390497@lid")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("receipt ack should be buildable");
assert!(ack.attrs.get("class").is_some_and(|v| v == "receipt"));
assert!(
ack.attrs
.get("recipient")
.is_some_and(|v| v == "242395589390497@lid"),
"receipt ACK must echo the incoming `recipient` attribute"
);
}
#[test]
fn test_build_ack_node_for_message_without_recipient_omits_recipient() {
let incoming = NodeBuilder::new("message")
.attr("from", "120363161500776365@g.us")
.attr("id", "A5791A5392EF60E3FB06")
.attr("type", "text")
.attr("participant", "181531758878822@lid")
.build();
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
.expect("message ack should be buildable");
assert!(
!ack.attrs.contains_key("recipient"),
"ACK must NOT add `recipient` when the incoming stanza has none"
);
}
#[test]
fn test_encode_ack_bytes_roundtrip_recipient() {
let own_device_pn: Jid = "155500012345:48@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let with_recipient = NodeBuilder::new("message")
.attr("from", "166361967902821@lid")
.attr("id", "2A32F960553696093D99")
.attr("type", "text")
.attr("recipient", "146991363395800@lid")
.build();
let buf = encode_ack_bytes(
&with_recipient.as_node_ref(),
Some(&own_device_pn),
AckParticipantPolicy::Preserve,
)
.expect("encode_ack_bytes should produce bytes");
let decoded =
wacore_binary::marshal::unmarshal_ref(&buf[1..]).expect("encoded ack should decode");
assert_eq!(decoded.tag, "ack");
assert!(
decoded
.get_attr("class")
.is_some_and(|v| v.as_str() == "message"),
"decoded ack must have class=message"
);
assert!(
decoded
.get_attr("recipient")
.is_some_and(|v| v.as_str() == "146991363395800@lid"),
"encode_ack_bytes must echo `recipient` onto the wire"
);
assert!(
decoded
.get_attr("type")
.is_some_and(|value| value.as_str() == "text"),
"generic message ACK must echo its explicit type"
);
let without_recipient = NodeBuilder::new("message")
.attr("from", "120363161500776365@g.us")
.attr("id", "A5791A5392EF60E3FB06")
.attr("type", "text")
.attr("participant", "181531758878822@lid")
.build();
let buf = encode_ack_bytes(
&without_recipient.as_node_ref(),
Some(&own_device_pn),
AckParticipantPolicy::Preserve,
)
.expect("encode_ack_bytes should produce bytes");
let decoded =
wacore_binary::marshal::unmarshal_ref(&buf[1..]).expect("encoded ack should decode");
assert!(
decoded.get_attr("recipient").is_none(),
"encode_ack_bytes must not synthesise `recipient` when absent"
);
}
#[test]
fn test_encode_ack_bytes_requires_public_response_inputs() {
let without_id = NodeBuilder::new("receipt")
.attr("from", "12025550111@s.whatsapp.net")
.build();
assert!(matches!(
encode_ack_bytes(
&without_id.as_node_ref(),
None,
AckParticipantPolicy::Preserve,
),
Err(crate::features::StanzaResponseError::MissingAttribute("id"))
));
let empty_id = NodeBuilder::new("receipt")
.attr("id", "")
.attr("from", "12025550111@s.whatsapp.net")
.build();
assert!(matches!(
encode_ack_bytes(
&empty_id.as_node_ref(),
None,
AckParticipantPolicy::Preserve,
),
Err(crate::features::StanzaResponseError::MissingAttribute("id"))
));
let without_from = NodeBuilder::new("receipt")
.attr("id", "MISSING-FROM")
.build();
assert!(matches!(
encode_ack_bytes(
&without_from.as_node_ref(),
None,
AckParticipantPolicy::Preserve,
),
Err(crate::features::StanzaResponseError::MissingAttribute(
"from"
))
));
let empty_from = NodeBuilder::new("receipt")
.attr("id", "EMPTY-FROM")
.attr("from", "")
.build();
assert!(matches!(
encode_ack_bytes(
&empty_from.as_node_ref(),
None,
AckParticipantPolicy::Preserve,
),
Err(crate::features::StanzaResponseError::MissingAttribute(
"from"
))
));
let message = NodeBuilder::new("message")
.attr("id", "MISSING-IDENTITY")
.attr("from", "12025550111@s.whatsapp.net")
.build();
assert!(matches!(
encode_ack_bytes(&message.as_node_ref(), None, AckParticipantPolicy::Preserve,),
Err(crate::features::StanzaResponseError::MissingLocalIdentity)
));
}
#[test]
fn test_encode_ack_bytes_preserves_specialized_receipt_rules() {
let from: Jid = "12025550111@s.whatsapp.net".parse().unwrap();
let receipt = NodeBuilder::new("receipt")
.attr("id", "RECEIPT-ACK")
.attr("from", &from)
.attr("participant", "12025550111@s.whatsapp.net")
.attr("type", "retry")
.build();
let bytes = encode_ack_bytes(
&receipt.as_node_ref(),
None,
AckParticipantPolicy::OmitReceiptDestinationDuplicate,
)
.expect("complete receipt should produce an ack");
let ack = wacore_binary::marshal::unmarshal_ref(&bytes[1..])
.expect("encoded receipt ack should decode");
assert!(
ack.get_attr("class")
.is_some_and(|value| value.as_str() == "receipt")
);
assert!(
ack.get_attr("type")
.is_some_and(|value| value.as_str() == "retry")
);
assert!(
ack.get_attr("participant").is_none(),
"receipt ack must omit a participant that duplicates its destination"
);
assert!(ack.get_attr("from").is_none());
let group_receipt = NodeBuilder::new("receipt")
.attr("id", "GROUP-RECEIPT-ACK")
.attr("from", "120363098765432100@g.us")
.attr("participant", "12025550111:7@s.whatsapp.net")
.build();
let bytes = encode_ack_bytes(
&group_receipt.as_node_ref(),
None,
AckParticipantPolicy::OmitReceiptDestinationDuplicate,
)
.expect("group receipt should produce an ack");
let ack = wacore_binary::marshal::unmarshal_ref(&bytes[1..])
.expect("encoded group receipt ack should decode");
assert!(
ack.get_attr("participant")
.is_some_and(|value| value.as_str() == "12025550111:7@s.whatsapp.net"),
"receipt ack must preserve a participant distinct from its destination"
);
let generic = NodeBuilder::new("message")
.attr("id", "MESSAGE-ACK")
.attr("from", "12025550111@s.whatsapp.net")
.attr("participant", &from)
.build();
let bytes = encode_ack_bytes(
&generic.as_node_ref(),
Some(&from),
AckParticipantPolicy::Preserve,
)
.expect("complete message should produce an ack");
let ack = wacore_binary::marshal::unmarshal_ref(&bytes[1..])
.expect("encoded message ack should decode");
assert!(
ack.get_attr("participant")
.is_some_and(|value| value.as_str() == "12025550111@s.whatsapp.net"),
"generic ack must not inherit the receipt-only participant rule"
);
}
#[test]
fn test_encode_ack_bytes_compares_jid_participants_by_display() {
let from = Jid {
user: "12025550111".into(),
server: wacore_binary::Server::Hosted,
agent: 1,
device: 7,
integrator: 0,
};
let participant = Jid {
agent: 2,
..from.clone()
};
assert_eq!(from.to_string(), participant.to_string());
let receipt = NodeBuilder::new("receipt")
.attr("id", "DISPLAY-EQUIVALENT-PARTICIPANT")
.attr("from", &from)
.attr("participant", &participant)
.build();
let bytes = encode_ack_bytes(
&receipt.as_node_ref(),
None,
AckParticipantPolicy::OmitReceiptDestinationDuplicate,
)
.expect("complete receipt should produce an ack");
let ack = wacore_binary::marshal::unmarshal_ref(&bytes[1..])
.expect("encoded receipt ack should decode");
assert!(
ack.get_attr("participant").is_none(),
"receipt ack must omit display-equivalent participant JIDs"
);
}
#[test]
fn test_encode_ack_bytes_drops_encrypt_identity_notification_type() {
let notification = NodeBuilder::new("notification")
.attr("id", "IDENTITY-NOTIFICATION")
.attr("from", "12025550111@s.whatsapp.net")
.attr("type", "encrypt")
.children([NodeBuilder::new("identity").build()])
.build();
let bytes = encode_ack_bytes(
¬ification.as_node_ref(),
None,
AckParticipantPolicy::Preserve,
)
.expect("complete notification should produce an ack");
let ack = wacore_binary::marshal::unmarshal_ref(&bytes[1..])
.expect("encoded notification ack should decode");
assert!(
ack.get_attr("class")
.is_some_and(|value| value.as_str() == "notification")
);
assert!(ack.get_attr("type").is_none());
assert!(ack.get_attr("from").is_none());
}
#[test]
fn test_encode_ack_bytes_preserves_call_class_and_type() {
let call = NodeBuilder::new("call")
.attr("id", "CALL-ACK")
.attr("from", "12025550111@s.whatsapp.net")
.attr("type", "offer_notice")
.build();
let bytes = encode_ack_bytes(&call.as_node_ref(), None, AckParticipantPolicy::Preserve)
.expect("complete call should produce an ack");
let ack =
wacore_binary::marshal::unmarshal_ref(&bytes[1..]).expect("encoded call ack should decode");
assert!(
ack.get_attr("class")
.is_some_and(|value| value.as_str() == "call")
);
assert!(
ack.get_attr("type")
.is_some_and(|value| value.as_str() == "offer_notice")
);
assert!(ack.get_attr("from").is_none());
}
#[test]
fn test_message_ack_source_node_own_device_addressing() {
use crate::types::message::{MessageInfo, MessageSource};
let info = MessageInfo {
id: "AC055553E56A2C12DE592DAD6353C477".to_string(),
source: MessageSource {
sender: "236395184570386@lid".parse().expect("sender"),
chat: "156535032389744@lid".parse().expect("chat"),
recipient: Some("156535032389744@lid".parse().expect("recipient")),
is_group: false,
..Default::default()
},
..Default::default()
};
let own_device_pn: Jid = "559984726662:95@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let source = message_ack_source_node(&info);
let built = build_ack_node(&source.as_node_ref(), Some(&own_device_pn))
.expect("message ack should be buildable");
assert!(built.attrs.get("class").is_some_and(|v| v == "message"));
assert!(
built
.attrs
.get("to")
.is_some_and(|v| v == "236395184570386@lid"),
"ack `to` must be the original `from` (own LID), not the chat"
);
assert!(
built
.attrs
.get("recipient")
.is_some_and(|v| v == "156535032389744@lid"),
"ack must echo `recipient` so the server can route/clear it"
);
assert!(
!built.attrs.contains_key("type"),
"message-class acks never carry a `type`"
);
}
#[test]
fn test_message_ack_source_node_incoming_dm_addressing() {
use crate::types::message::{MessageInfo, MessageSource};
let info = MessageInfo {
id: "MSGID".to_string(),
source: MessageSource {
sender: "5511999998888:3@s.whatsapp.net".parse().expect("sender"),
chat: "5511999998888@s.whatsapp.net".parse().expect("chat"),
is_group: false,
..Default::default()
},
..Default::default()
};
let own_device_pn: Jid = "559984726662:95@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let source = message_ack_source_node(&info);
let built = build_ack_node(&source.as_node_ref(), Some(&own_device_pn))
.expect("dm ack should be buildable");
assert!(
built
.attrs
.get("to")
.is_some_and(|v| v == "5511999998888:3@s.whatsapp.net"),
"ack `to` must be the device-qualified sender (the original `from`)"
);
assert!(!built.attrs.contains_key("recipient"));
assert!(!built.attrs.contains_key("participant"));
}
#[test]
fn test_message_ack_source_node_status_addressing() {
use crate::types::message::{MessageInfo, MessageSource};
let info = MessageInfo {
id: "STATUSMSG".to_string(),
source: MessageSource {
chat: "status@broadcast".parse().expect("status chat"),
sender: "181531758878822@lid".parse().expect("participant"),
is_group: true,
..Default::default()
},
..Default::default()
};
let own_device_pn: Jid = "559984726662:95@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let source = message_ack_source_node(&info);
let built = build_ack_node(&source.as_node_ref(), Some(&own_device_pn))
.expect("status ack should be buildable");
assert!(
built
.attrs
.get("to")
.is_some_and(|v| v == "status@broadcast"),
"status ack `to` must be the status chat, not the sender"
);
assert!(
built
.attrs
.get("participant")
.is_some_and(|v| v == "181531758878822@lid"),
"status ack must preserve the sending participant"
);
}
#[test]
fn test_message_ack_source_node_group_addressing() {
use crate::types::message::{MessageInfo, MessageSource};
let info = MessageInfo {
id: "GROUPMSGID".to_string(),
source: MessageSource {
chat: "120363011111111111@g.us".parse().expect("group"),
sender: "181531758878822@lid".parse().expect("participant"),
is_group: true,
..Default::default()
},
..Default::default()
};
let own_device_pn: Jid = "559984726662:95@s.whatsapp.net"
.parse()
.expect("own device PN JID should parse");
let source = message_ack_source_node(&info);
let built = build_ack_node(&source.as_node_ref(), Some(&own_device_pn))
.expect("group message ack should be buildable");
assert!(
built
.attrs
.get("to")
.is_some_and(|v| v == "120363011111111111@g.us"),
"group ack `to` must be the group JID"
);
assert!(
built
.attrs
.get("participant")
.is_some_and(|v| v == "181531758878822@lid"),
"group ack must preserve the sending `participant`"
);
}
#[tokio::test]
async fn test_handle_iq_ping_without_id() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let ping_node = NodeBuilder::new("iq")
.attr("type", "get")
.attr("from", SERVER_JID)
.attr("xmlns", "urn:xmpp:ping")
.build();
let handled = client.handle_iq(&ping_node.as_node_ref()).await;
assert!(
handled,
"handle_iq must recognize ping without id attribute"
);
}
#[test]
fn test_fibonacci_backoff_sequence() {
let expected_base_ms = [1000, 1000, 2000, 3000, 5000, 8000, 13000, 21000];
for (attempt, &base) in expected_base_ms.iter().enumerate() {
let delay = fibonacci_backoff(attempt as u32);
let ms = delay.as_millis() as u64;
let low = base - base / 10;
let high = base + base / 10;
assert!(
ms >= low && ms <= high,
"attempt {attempt}: expected {low}..={high}ms, got {ms}ms"
);
}
}
#[test]
fn test_fibonacci_backoff_max_900s() {
let delay = fibonacci_backoff(100);
let ms = delay.as_millis() as u64;
assert!(
ms <= 990_000,
"should never exceed 900s + 10% jitter, got {ms}ms"
);
assert!(
ms >= 810_000,
"should be at least 900s - 10% jitter, got {ms}ms"
);
}
#[test]
fn test_fibonacci_backoff_first_attempt_is_1s() {
let delay = fibonacci_backoff(0);
let ms = delay.as_millis() as u64;
assert!(
(900..=1100).contains(&ms),
"first attempt should be ~1s (±10%), got {ms}ms"
);
}
#[test]
fn should_reset_backoff_requires_uptime_window_and_no_penalty() {
let start = 1_000_000i64;
let stable = start + Client::STABLE_CONNECTION_RESET_MS;
assert!(!should_reset_backoff(0, 1_000_000, false));
assert!(!should_reset_backoff(
start,
start + Client::STABLE_CONNECTION_RESET_MS - 1,
false
));
assert!(should_reset_backoff(start, stable, false));
assert!(should_reset_backoff(start, start + 60_000, false));
assert!(!should_reset_backoff(start, stable, true));
assert!(!should_reset_backoff(start, start + 60_000, true));
assert!(!should_reset_backoff(start, start - 5_000, false));
}
#[tokio::test]
async fn test_stream_error_401_disables_reconnect() {
let client = create_offline_sync_test_client().await;
let node = NodeBuilder::new("stream:error").attr("code", "401").build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
!client.enable_auto_reconnect.load(Ordering::Relaxed),
"401 should disable auto-reconnect"
);
}
#[tokio::test]
async fn test_stream_error_409_disables_reconnect() {
let client = create_offline_sync_test_client().await;
let node = NodeBuilder::new("stream:error").attr("code", "409").build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
!client.enable_auto_reconnect.load(Ordering::Relaxed),
"409 should disable auto-reconnect"
);
}
#[tokio::test]
async fn test_stream_error_429_keeps_reconnect_with_backoff() {
let client = create_offline_sync_test_client().await;
client.is_logged_in.store(true, Ordering::Relaxed);
let before = client.auto_reconnect_errors.load(Ordering::Relaxed);
let node = NodeBuilder::new("stream:error").attr("code", "429").build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
client.enable_auto_reconnect.load(Ordering::Relaxed),
"429 should keep auto-reconnect enabled"
);
assert!(
!client.is_logged_in.load(Ordering::Relaxed),
"429 must clear is_logged_in so sends bail before the server flags abuse"
);
assert!(
!client.expected_disconnect.load(Ordering::Relaxed),
"429 must not mark the disconnect as expected (auto-reconnect path)"
);
let after = client.auto_reconnect_errors.load(Ordering::Relaxed);
assert_eq!(
after,
before + 5,
"429 should increase backoff by exactly 5: before={before}, after={after}"
);
}
#[tokio::test]
async fn test_stream_error_503_keeps_reconnect() {
let client = create_offline_sync_test_client().await;
client.is_logged_in.store(true, Ordering::Relaxed);
let node = NodeBuilder::new("stream:error").attr("code", "503").build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
client.enable_auto_reconnect.load(Ordering::Relaxed),
"503 should keep auto-reconnect enabled"
);
assert!(
!client.is_logged_in.load(Ordering::Relaxed),
"503 must clear is_logged_in so sends bail against the dying socket"
);
assert!(
!client.expected_disconnect.load(Ordering::Relaxed),
"503 must not mark the disconnect as expected (auto-reconnect path)"
);
}
#[tokio::test]
async fn test_stream_error_unknown_keeps_connection_alive() {
let client = create_offline_sync_test_client().await;
client.is_logged_in.store(true, Ordering::Relaxed);
let node = NodeBuilder::new("stream:error").build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
client.is_logged_in.load(Ordering::Relaxed),
"unknown stream:error must NOT log the client out"
);
assert!(
!client.expected_disconnect.load(Ordering::Relaxed),
"unknown stream:error must not mark the disconnect as expected"
);
assert!(
client.enable_auto_reconnect.load(Ordering::Relaxed),
"unknown stream:error must keep auto-reconnect enabled"
);
}
#[tokio::test]
async fn test_stream_error_ack_shaped_does_not_force_shutdown() {
let client = create_offline_sync_test_client().await;
client.is_logged_in.store(true, Ordering::Relaxed);
let ack_child = NodeBuilder::new("ack")
.attr("class", "message")
.attr("type", "text")
.attr("id", "2A32F960553696093D99")
.build();
let node = NodeBuilder::new("stream:error")
.children([ack_child])
.build();
client.handle_stream_error(&node.as_node_ref()).await;
assert!(
client.is_logged_in.load(Ordering::Relaxed),
"ack-shaped stream:error must NOT log the client out"
);
assert!(
!client.expected_disconnect.load(Ordering::Relaxed),
"ack-shaped stream:error must not mark the disconnect as expected"
);
}
#[tokio::test]
async fn test_custom_cache_config_is_respected() {
use crate::cache_config::{CacheConfig, CacheEntryConfig};
use std::time::Duration;
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let custom_config = CacheConfig {
group_cache: CacheEntryConfig::new(Some(Duration::from_secs(60)), 10),
device_registry_cache: CacheEntryConfig::new(Some(Duration::from_secs(60)), 10),
..CacheConfig::default()
};
let (client, _rx) = Client::new_with_cache_config(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
custom_config,
)
.await;
assert!(!client.is_logged_in());
}
#[tokio::test]
async fn held_group_distribution_lane_survives_capacity_pressure() {
let config = CacheConfig {
group_distribution_locks_capacity: 1,
..Default::default()
};
let client = crate::test_utils::create_test_client_with_config(
"group_distribution_eviction",
Arc::new(MockHttpClient),
config,
)
.await;
let first: Jid = "120363000000000011@g.us".parse().unwrap();
let second: Jid = "120363000000000012@g.us".parse().unwrap();
let third: Jid = "120363000000000013@g.us".parse().unwrap();
let held = client.group_distribution_lock(&first).await;
drop(client.group_distribution_lock(&second).await);
drop(client.group_distribution_lock(&third).await);
let first_again = client
.group_distribution_locks
.get(&first)
.await
.expect("held lane must remain cached");
assert!(
first_again.try_lock().is_none(),
"capacity pressure must not mint a second live lane"
);
let report = client.memory_report().await;
assert_eq!(report.group_distribution_locks, 2);
assert_eq!(report.group_distribution_lock_evictions, 1);
assert_eq!(report.group_distribution_lock_eviction_blocks, 2);
drop(held);
assert!(first_again.try_lock().is_some());
}
#[tokio::test]
async fn active_chat_lane_survives_capacity_pressure() {
fn test_lane() -> (ChatLane, async_channel::Receiver<QueuedChatMessage>) {
let (queue_tx, queue_rx) = async_channel::unbounded();
(
ChatLane {
enqueue_lock: Arc::new(Mutex::new(())),
queue_tx,
},
queue_rx,
)
}
let config = CacheConfig {
chat_lanes_capacity: 1,
..Default::default()
};
let client = crate::test_utils::create_test_client_with_config(
"chat_lane_eviction",
Arc::new(MockHttpClient),
config,
)
.await;
let first: Jid = "120363000000000021@g.us".parse().unwrap();
let second: Jid = "120363000000000022@g.us".parse().unwrap();
let (first_lane, first_rx) = test_lane();
let first_tx_probe = first_lane.queue_tx.clone();
client.chat_lanes.insert(first.clone(), first_lane).await;
let first_lane = client.chat_lanes.get(&first).await.unwrap();
let node = NodeBuilder::new("message")
.attr("from", first.clone())
.attr("id", "ACTIVE-LANE-1")
.build();
first_lane.try_enqueue(node_to_owned_ref(node)).unwrap();
drop(first_lane);
let active_message = first_rx.recv().await.unwrap();
let (second_lane, _second_rx) = test_lane();
client.chat_lanes.insert(second, second_lane).await;
let first_again = client
.chat_lanes
.get(&first)
.await
.expect("an active lane must remain cached");
assert!(first_again.queue_tx.same_channel(&first_tx_probe));
let next_node = NodeBuilder::new("message")
.attr("from", first.clone())
.attr("id", "ACTIVE-LANE-2")
.build();
first_again
.try_enqueue(node_to_owned_ref(next_node))
.unwrap();
drop(first_again);
drop(active_message);
let next_active_message = first_rx.recv().await.unwrap();
let third: Jid = "120363000000000023@g.us".parse().unwrap();
let (third_lane, _third_rx) = test_lane();
client.chat_lanes.insert(third, third_lane).await;
assert!(
client.chat_lanes.get(&first).await.is_some(),
"a lane with an in-flight message must not be evicted"
);
drop(next_active_message);
let fourth: Jid = "120363000000000024@g.us".parse().unwrap();
let (fourth_lane, _fourth_rx) = test_lane();
client.chat_lanes.insert(fourth, fourth_lane).await;
assert!(
client.chat_lanes.get(&first).await.is_none(),
"an idle lane must become evictable again"
);
}
#[tokio::test]
async fn test_is_connected_not_affected_by_mutex_contention() {
use crate::socket::NoiseSocket;
use wacore::handshake::NoiseCipher;
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
assert!(!client.is_connected(), "should start disconnected");
let transport: Arc<dyn crate::transport::Transport> =
Arc::new(crate::transport::mock::MockTransport);
let key = [0u8; 32];
let write_key = NoiseCipher::new(&key).expect("valid key");
let read_key = NoiseCipher::new(&key).expect("valid key");
let noise_socket = NoiseSocket::new(
Arc::new(crate::runtime_impl::TokioRuntime),
transport,
write_key,
read_key,
);
*client.noise_socket.lock().await = Some(Arc::new(noise_socket));
client.is_connected.store(true, Ordering::Release);
assert!(client.is_connected(), "should report connected");
let _guard = client.noise_socket.lock().await;
assert!(
client.is_connected(),
"is_connected() must return true even while noise_socket mutex is held"
);
}
#[tokio::test]
async fn disconnect_does_not_signal_connection_cleanup_before_outbound_flush() {
use crate::socket::NoiseSocket;
use async_trait::async_trait;
use bytes::Bytes;
use wacore::handshake::NoiseCipher;
struct BlockingTransport {
send_started: async_channel::Sender<()>,
release_send: async_channel::Receiver<()>,
send_done: Arc<AtomicBool>,
disconnect_called: Arc<AtomicBool>,
disconnect_before_send_done: Arc<AtomicBool>,
}
#[async_trait]
impl crate::transport::Transport for BlockingTransport {
async fn send(&self, _data: Bytes) -> Result<(), anyhow::Error> {
let _ = self.send_started.try_send(());
let _ = self.release_send.recv().await;
self.send_done.store(true, Ordering::Release);
Ok(())
}
async fn disconnect(&self) {
if !self.send_done.load(Ordering::Acquire) {
self.disconnect_before_send_done
.store(true, Ordering::Release);
}
self.disconnect_called.store(true, Ordering::Release);
}
}
let client = crate::test_utils::create_test_client().await;
let (send_started_tx, send_started_rx) = async_channel::bounded(1);
let (release_send_tx, release_send_rx) = async_channel::bounded(1);
let send_done = Arc::new(AtomicBool::new(false));
let disconnect_called = Arc::new(AtomicBool::new(false));
let disconnect_before_send_done = Arc::new(AtomicBool::new(false));
let transport_impl = Arc::new(BlockingTransport {
send_started: send_started_tx,
release_send: release_send_rx,
send_done: Arc::clone(&send_done),
disconnect_called: Arc::clone(&disconnect_called),
disconnect_before_send_done: Arc::clone(&disconnect_before_send_done),
});
let transport: Arc<dyn crate::transport::Transport> = transport_impl;
let key = [0u8; 32];
let write_key = NoiseCipher::new(&key).expect("valid key");
let read_key = NoiseCipher::new(&key).expect("valid key");
let noise_socket = NoiseSocket::new(
client.runtime.clone(),
Arc::clone(&transport),
write_key,
read_key,
);
*client.transport.lock().await = Some(transport);
*client.noise_socket.lock().await = Some(Arc::new(noise_socket));
client.is_connected.store(true, Ordering::Release);
let cleanup_signal = client.connection_shutdown_signal();
let cleanup_client = Arc::clone(&client);
let cleanup_task = tokio::spawn(async move {
wacore::runtime::wait_for_shutdown(&cleanup_signal).await;
cleanup_client.cleanup_connection_state().await;
});
let send_client = Arc::clone(&client);
client.outbound_flush.spawn(&*client.runtime, async move {
let receipt = NodeBuilder::new("receipt")
.attr("id", "TEST-FLUSH-ORDER")
.attr("to", "1234567890@s.whatsapp.net")
.build();
let _ = send_client.send_node(receipt).await;
});
tokio::time::timeout(Duration::from_secs(1), send_started_rx.recv())
.await
.expect("tracked send should start")
.expect("send_started sender should stay open");
let disconnect_client = Arc::clone(&client);
let disconnect_task = tokio::spawn(async move {
disconnect_client.disconnect().await;
});
crate::test_utils::poll_until("disconnect to park on the outbound flush", || {
client.outbound_flush.flush_waiters() >= 1
})
.await;
assert!(
!client.connection_shutdown_signal().is_fired(),
"connection cleanup must not fire while outbound flush is blocked"
);
assert!(
!disconnect_called.load(Ordering::Acquire),
"transport must stay open while outbound flush is blocked"
);
release_send_tx
.send(())
.await
.expect("blocked send should still be waiting");
tokio::time::timeout(Duration::from_secs(1), disconnect_task)
.await
.expect("disconnect should finish")
.expect("disconnect task should not panic");
tokio::time::timeout(Duration::from_secs(1), cleanup_task)
.await
.expect("cleanup should finish")
.expect("cleanup task should not panic");
assert!(send_done.load(Ordering::Acquire));
assert!(disconnect_called.load(Ordering::Acquire));
assert!(
!disconnect_before_send_done.load(Ordering::Acquire),
"cleanup closed the transport before the tracked send completed"
);
}
async fn install_test_noise_socket(
client: &Arc<Client>,
transport: Arc<dyn crate::transport::Transport>,
runtime: Arc<dyn Runtime>,
) {
use crate::socket::NoiseSocket;
use wacore::handshake::NoiseCipher;
let key = [0u8; 32];
let noise_socket = NoiseSocket::new(
runtime,
transport,
NoiseCipher::new(&key).expect("valid key"),
NoiseCipher::new(&key).expect("valid key"),
);
*client.noise_socket.lock().await = Some(Arc::new(noise_socket));
client.set_connected_for_test(true);
}
fn receipt_test_info(id: &str) -> Arc<crate::types::message::MessageInfo> {
Arc::new(crate::types::message::MessageInfo {
id: id.to_string(),
source: crate::types::message::MessageSource {
chat: "15550001111@s.whatsapp.net".parse().unwrap(),
sender: "15550001111@s.whatsapp.net".parse().unwrap(),
..Default::default()
},
..Default::default()
})
}
#[derive(Debug)]
struct DropSpawnRuntime;
#[async_trait::async_trait]
impl Runtime for DropSpawnRuntime {
fn spawn(
&self,
_future: std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
) -> wacore::runtime::AbortHandle {
wacore::runtime::AbortHandle::noop()
}
fn sleep(&self, _duration: Duration) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async {})
}
fn spawn_blocking(
&self,
operation: Box<dyn FnOnce() + Send + 'static>,
) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move { operation() })
}
fn yield_now(&self) -> Option<std::pin::Pin<Box<dyn Future<Output = ()> + Send>>> {
None
}
}
#[tokio::test]
async fn raw_bytes_burst_drains_and_reuses_input_on_happy_paths() {
use crate::transport::mock::CapturingMockTransport;
let client = crate::test_utils::create_test_client().await;
let transport = Arc::new(CapturingMockTransport::new());
install_test_noise_socket(
&client,
transport.clone(),
Arc::new(crate::runtime_impl::TokioRuntime),
)
.await;
let mut frames = Vec::with_capacity(4);
let retained_capacity = frames.capacity();
frames.push(vec![0x11; 32]);
let mut results = Vec::with_capacity(4);
let results_ptr = results.as_ptr();
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("installed socket");
assert_eq!(results.len(), 1);
assert!(results.iter().all(|result| result.is_ok()));
assert_eq!(
results.as_ptr(),
results_ptr,
"the single-frame path must fill the caller's buffer, not replace it"
);
assert!(frames.is_empty(), "the single-frame fast path must drain");
assert_eq!(frames.capacity(), retained_capacity);
frames.extend((0..4).map(|index| vec![index; 32]));
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("installed socket");
assert_eq!(results.len(), 4);
assert!(results.iter().all(|result| result.is_ok()));
assert_eq!(
results.as_ptr(),
results_ptr,
"the caller's results buffer must be the same allocation, not an equal one"
);
assert!(frames.is_empty(), "the joined path must drain");
assert_eq!(frames.capacity(), retained_capacity);
assert_eq!(transport.sent_count(), 5, "every frame must reach the wire");
assert_eq!(
transport.write_count(),
2,
"the four-frame call must remain one coalesced transport write"
);
}
#[tokio::test]
async fn raw_bytes_burst_drains_input_when_disconnected() {
let client = crate::test_utils::create_test_client().await;
let mut frames = Vec::with_capacity(4);
let retained_capacity = frames.capacity();
frames.extend([vec![0x21; 32], vec![0x22; 32]]);
let mut results = Vec::new();
let result = client.send_raw_bytes_burst(&mut frames, &mut results).await;
assert!(
matches!(result, Err(ClientError::NotConnected)),
"a missing socket must remain an outer NotConnected error: {result:?}"
);
assert!(frames.is_empty(), "the outer-error path must also drain");
assert_eq!(frames.capacity(), retained_capacity);
}
#[tokio::test]
async fn raw_bytes_burst_surfaces_transport_then_poisoned_per_frame() {
use crate::socket::error::EncryptSendErrorKind;
use crate::transport::mock::CapturingMockTransport;
let client = crate::test_utils::create_test_client().await;
let transport = Arc::new(CapturingMockTransport::new());
transport.fail_next_sends(1);
install_test_noise_socket(
&client,
transport.clone(),
Arc::new(crate::runtime_impl::TokioRuntime),
)
.await;
let mut frames = Vec::with_capacity(4);
let retained_capacity = frames.capacity();
frames.push(vec![0x31; 32]);
let mut results = Vec::new();
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("the socket lookup itself succeeds");
let transport_error = results
.pop()
.expect("one result")
.expect_err("the transport is configured to fail");
assert!(matches!(
transport_error.kind,
EncryptSendErrorKind::Transport
));
assert!(transport_error.is_transport_unavailable());
assert!(frames.is_empty());
assert_eq!(frames.capacity(), retained_capacity);
frames.push(vec![0x32; 32]);
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("the installed socket remains reachable");
let poisoned_error = results
.pop()
.expect("one result")
.expect_err("the sender must reject work after an ambiguous write");
assert!(matches!(
poisoned_error.kind,
EncryptSendErrorKind::Poisoned
));
assert!(poisoned_error.is_transport_unavailable());
assert!(frames.is_empty());
assert_eq!(frames.capacity(), retained_capacity);
assert_eq!(transport.failed_sends(), 1);
assert_eq!(
transport.write_count(),
0,
"a poisoned sender must not attempt another transport write"
);
}
#[tokio::test]
async fn a_multi_frame_burst_stays_one_ordered_write() {
use crate::transport::mock::CapturingMockTransport;
let client = crate::test_utils::create_test_client().await;
let transport = Arc::new(CapturingMockTransport::new());
install_test_noise_socket(
&client,
transport.clone(),
Arc::new(crate::runtime_impl::TokioRuntime),
)
.await;
let mut frames: Vec<Vec<u8>> = (1..=4).map(|n| vec![n as u8; 16 * n]).collect();
let mut results = Vec::new();
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("installed socket");
assert_eq!(results.len(), 4);
assert!(results.iter().all(|result| result.is_ok()));
assert!(
frames.is_empty(),
"the burst must drain its input, which the workers rely on to refill it"
);
assert_eq!(
transport.write_count(),
1,
"the whole burst must reach the transport as one write"
);
let sent = transport.sent();
assert_eq!(sent.len(), 4, "every frame must reach the wire");
const TAG_AND_PREFIX: usize = 16 + wacore::framing::FRAME_LENGTH_SIZE;
let lengths: Vec<usize> = sent.iter().map(|frame| frame.len()).collect();
assert_eq!(
lengths,
(1..=4)
.map(|n| 16 * n + TAG_AND_PREFIX)
.collect::<Vec<usize>>(),
"frames must reach the wire in the order they were given"
);
}
#[tokio::test]
async fn raw_bytes_burst_surfaces_a_closed_sender_per_frame() {
use crate::socket::error::EncryptSendErrorKind;
let client = crate::test_utils::create_test_client().await;
install_test_noise_socket(
&client,
Arc::new(crate::transport::mock::MockTransport),
Arc::new(DropSpawnRuntime),
)
.await;
let mut frames = Vec::with_capacity(4);
let retained_capacity = frames.capacity();
frames.push(vec![0x41; 32]);
let mut results = Vec::new();
client
.send_raw_bytes_burst(&mut frames, &mut results)
.await
.expect("the installed socket remains reachable");
let error = results
.pop()
.expect("one result")
.expect_err("the sender receiver was dropped at construction");
assert!(matches!(error.kind, EncryptSendErrorKind::ChannelClosed));
assert!(error.is_transport_unavailable());
assert!(frames.is_empty());
assert_eq!(frames.capacity(), retained_capacity);
}
#[tokio::test]
async fn delivery_receipt_worker_sends_and_releases_flush() {
use crate::socket::NoiseSocket;
use async_trait::async_trait;
use bytes::Bytes;
use wacore::handshake::NoiseCipher;
struct CountingTransport {
sends: Arc<AtomicUsize>,
}
#[async_trait]
impl crate::transport::Transport for CountingTransport {
async fn send(&self, _data: Bytes) -> Result<(), anyhow::Error> {
self.sends.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn disconnect(&self) {}
}
let client = crate::test_utils::create_test_client().await;
let sends = Arc::new(AtomicUsize::new(0));
let transport: Arc<dyn crate::transport::Transport> = Arc::new(CountingTransport {
sends: Arc::clone(&sends),
});
let key = [0u8; 32];
let noise_socket = NoiseSocket::new(
client.runtime.clone(),
Arc::clone(&transport),
NoiseCipher::new(&key).expect("valid key"),
NoiseCipher::new(&key).expect("valid key"),
);
*client.transport.lock().await = Some(transport);
*client.noise_socket.lock().await = Some(Arc::new(noise_socket));
client.is_connected.store(true, Ordering::Release);
client.ack_received_message(&receipt_test_info("RCPT-WORKER-1"));
let deadline = wacore::time::Instant::now() + Duration::from_secs(2);
while sends.load(Ordering::SeqCst) == 0 {
assert!(
wacore::time::Instant::now() < deadline,
"delivery receipt was never sent by the worker"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(sends.load(Ordering::SeqCst), 1);
client
.outbound_flush
.flush(&*client.runtime, Duration::from_secs(1))
.await;
assert_eq!(
client.outbound_flush.pending(),
0,
"worker must release the flush guard after the send"
);
}
#[tokio::test]
async fn delivery_receipt_worker_releases_flush_after_transport_and_poisoned_failures() {
use crate::transport::mock::CapturingMockTransport;
let client = crate::test_utils::create_test_client().await;
let transport = Arc::new(CapturingMockTransport::new());
transport.fail_next_sends(1);
install_test_noise_socket(
&client,
transport.clone(),
Arc::new(crate::runtime_impl::TokioRuntime),
)
.await;
client.ack_received_message(&receipt_test_info("RCPT-FAIL-1"));
crate::test_utils::wait_for_outbound_tasks(&client).await;
assert_eq!(client.outbound_flush.pending(), 0);
assert_eq!(transport.failed_sends(), 1);
client.ack_received_message(&receipt_test_info("RCPT-POISONED-2"));
crate::test_utils::wait_for_outbound_tasks(&client).await;
assert_eq!(client.outbound_flush.pending(), 0);
assert_eq!(
transport.failed_sends(),
1,
"the poisoned sender must reject locally instead of touching transport"
);
assert_eq!(transport.write_count(), 0);
}
#[tokio::test]
async fn delivery_receipt_dropped_when_flush_scope_closed() {
let client = crate::test_utils::create_test_client().await;
client.outbound_flush.close();
client.ack_received_message(&receipt_test_info("RCPT-CLOSED-1"));
assert_eq!(
client.outbound_flush.pending(),
0,
"closed scope must not track new receipts"
);
assert!(
client.delivery_receipt_queue.get().is_none(),
"a dropped receipt must not reach the worker queue"
);
tokio::time::timeout(
Duration::from_secs(2),
client
.outbound_flush
.flush(&*client.runtime, Duration::from_secs(5)),
)
.await
.expect("flush must not wait when nothing was queued");
}
#[tokio::test]
async fn flush_waits_for_queued_delivery_receipts() {
use crate::socket::NoiseSocket;
use async_trait::async_trait;
use bytes::Bytes;
use wacore::handshake::NoiseCipher;
struct BlockingTransport {
send_started: async_channel::Sender<()>,
release_send: async_channel::Receiver<()>,
}
#[async_trait]
impl crate::transport::Transport for BlockingTransport {
async fn send(&self, _data: Bytes) -> Result<(), anyhow::Error> {
let _ = self.send_started.try_send(());
let _ = self.release_send.recv().await;
Ok(())
}
async fn disconnect(&self) {}
}
let client = crate::test_utils::create_test_client().await;
let (send_started_tx, send_started_rx) = async_channel::bounded(2);
let (release_send_tx, release_send_rx) = async_channel::bounded(2);
let transport: Arc<dyn crate::transport::Transport> = Arc::new(BlockingTransport {
send_started: send_started_tx,
release_send: release_send_rx,
});
let key = [0u8; 32];
let noise_socket = NoiseSocket::new(
client.runtime.clone(),
Arc::clone(&transport),
NoiseCipher::new(&key).expect("valid key"),
NoiseCipher::new(&key).expect("valid key"),
);
*client.transport.lock().await = Some(transport);
*client.noise_socket.lock().await = Some(Arc::new(noise_socket));
client.is_connected.store(true, Ordering::Release);
client.ack_received_message(&receipt_test_info("RCPT-QUEUE-1"));
tokio::time::timeout(Duration::from_secs(1), send_started_rx.recv())
.await
.expect("first receipt send should start")
.expect("send_started sender should stay open");
client.ack_received_message(&receipt_test_info("RCPT-QUEUE-2"));
assert_eq!(
client.outbound_flush.pending(),
2,
"both the in-flight and the queued receipt must hold flush guards"
);
let flush_client = Arc::clone(&client);
let flush_task = tokio::spawn(async move {
flush_client
.outbound_flush
.flush(&*flush_client.runtime, Duration::from_secs(5))
.await;
});
crate::test_utils::poll_until("the flusher to park on the outbound scope", || {
client.outbound_flush.flush_waiters() >= 1
})
.await;
assert!(
!flush_task.is_finished(),
"flush must wait while receipts are queued or in flight"
);
release_send_tx.send(()).await.expect("release first send");
release_send_tx.send(()).await.expect("release second send");
tokio::time::timeout(Duration::from_secs(2), flush_task)
.await
.expect("flush should finish once the queue drains")
.expect("flush task should not panic");
assert_eq!(client.outbound_flush.pending(), 0);
}
#[tokio::test]
async fn test_send_ack_for_returns_error_when_disconnected() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let receipt = NodeBuilder::new("receipt")
.attr("from", "120363040237990503@g.us")
.attr("id", "TEST-RECEIPT-ID")
.attr("participant", "236395184570386@lid")
.build();
let result = client.send_ack_for(&receipt.as_node_ref()).await;
assert!(
matches!(result, Err(ClientError::NotConnected)),
"send_ack_for must return Err(NotConnected) when disconnected, got: {result:?}"
);
}
#[tokio::test]
async fn outbound_teardown_gate_covers_both_disconnect_signals() {
let client = crate::test_utils::create_test_client().await;
client.set_connected_for_test(true);
client.expected_disconnect.store(false, Ordering::Relaxed);
assert!(
!client.outbound_teardown_in_progress(),
"a live connection must not be treated as tearing down"
);
client.expected_disconnect.store(true, Ordering::Relaxed);
assert!(
client.outbound_teardown_in_progress(),
"an expected disconnect (an intentional close, or a 515) must gate sends"
);
client.expected_disconnect.store(false, Ordering::Relaxed);
client.set_connected_for_test(false);
assert!(
client.outbound_teardown_in_progress(),
"a disconnected client must gate sends even without the expected flag"
);
}
#[tokio::test]
async fn deferred_ack_worker_drops_teardown_batches_and_recovers_cleanly() {
use crate::transport::mock::CapturingMockTransport;
let client = crate::test_utils::create_test_client().await;
let transport = Arc::new(CapturingMockTransport::new());
install_test_noise_socket(
&client,
transport.clone(),
Arc::new(crate::runtime_impl::TokioRuntime),
)
.await;
let receipt = |id| {
let node = NodeBuilder::new("receipt")
.attr("from", "15550001111@s.whatsapp.net")
.attr("id", id)
.build();
crate::test_utils::node_to_owned_ref(&node)
};
client.expected_disconnect.store(true, Ordering::Relaxed);
client
.process_node(receipt("ACK-EXPECTED-DISCONNECT"))
.await;
crate::test_utils::wait_for_outbound_tasks(&client).await;
assert_eq!(transport.sent_count(), 0);
client.expected_disconnect.store(false, Ordering::Relaxed);
client.set_connected_for_test(false);
client.process_node(receipt("ACK-DISCONNECTED")).await;
crate::test_utils::wait_for_outbound_tasks(&client).await;
assert_eq!(transport.sent_count(), 0);
client.set_connected_for_test(true);
client.process_node(receipt("ACK-LIVE")).await;
crate::test_utils::wait_for_outbound_tasks(&client).await;
assert_eq!(
transport.sent_count(),
1,
"only the live ack may survive into the reusable batch"
);
assert_eq!(client.outbound_flush.pending(), 0);
}
#[tokio::test]
async fn test_send_ack_for_returns_ok_on_expected_disconnect() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
client.expected_disconnect.store(true, Ordering::Relaxed);
let receipt = NodeBuilder::new("receipt")
.attr("from", "120363040237990503@g.us")
.attr("id", "TEST-RECEIPT-ID")
.build();
let result = client.send_ack_for(&receipt.as_node_ref()).await;
assert!(
result.is_ok(),
"send_ack_for should return Ok during expected disconnect"
);
}
#[tokio::test]
async fn per_connection_notify_leaves_terminal_signal_untouched() {
let client = crate::test_utils::create_test_client().await;
client.notify_connection_shutdown();
assert!(
!client.shutdown_signal().is_fired(),
"terminal shutdown must stay clean when only per-connection fires"
);
}
#[tokio::test]
async fn reset_gives_fresh_per_connection_notifier() {
let client = crate::test_utils::create_test_client().await;
client.notify_connection_shutdown();
assert!(
client.connection_shutdown_signal().is_fired(),
"subscriber BEFORE reset sees the notify on the current notifier"
);
client.reset_connection_shutdown();
assert!(
!client.connection_shutdown_signal().is_fired(),
"subscribers AFTER reset must NOT see the previous notifier's state"
);
}
#[tokio::test]
async fn captured_signal_keeps_observing_old_notifier_after_reset() {
let client = crate::test_utils::create_test_client().await;
let captured = client.connection_shutdown_signal();
client.notify_connection_shutdown();
client.reset_connection_shutdown();
assert!(
captured.is_fired(),
"captured signal must retain the pre-reset notifier's fired state"
);
}
#[tokio::test]
async fn terminal_disconnect_propagates_to_per_connection_signal() {
let client = crate::test_utils::create_test_client().await;
let conn_signal = client.connection_shutdown_signal();
client.disconnect().await;
assert!(
conn_signal.is_fired(),
"disconnect must fire per-connection via cleanup_connection_state"
);
assert!(
client.shutdown_signal().is_fired(),
"disconnect must also fire terminal"
);
}
#[tokio::test]
async fn dropping_fresh_client_releases_it_without_shutdown() {
let client = crate::test_utils::create_test_client().await;
let weak = Arc::downgrade(&client);
drop(client);
tokio::time::timeout(Duration::from_secs(5), async {
while weak.strong_count() != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap_or_else(|_| {
panic!(
"client is still retained by a background task (strong_count={})",
weak.strong_count()
)
});
}
#[tokio::test]
async fn ack_miss_path_does_not_heap_allocate() {
let client = crate::test_utils::create_test_client().await;
let node = Arc::new(owned_ack_node("3EB0A9252A8F12B7E2"));
let min_delta = crate::test_alloc::min_allocs(0, || {
let handled = client.handle_ack_response_arc(&node);
assert!(!handled, "no waiter is registered for this id");
});
assert_eq!(min_delta, 0, "ack miss path must not allocate");
}
#[tokio::test]
async fn stats_snapshot_reflects_counters() {
let client = crate::test_utils::create_test_client().await;
client.stats.record_frame_sent(150);
client.stats.record_recv_batch(300, 2);
client.stats.record_message_sent();
client.auto_reconnect_errors.store(3, Ordering::Relaxed);
let snap = client.stats();
assert_eq!(snap.bytes_sent, 150);
assert_eq!(snap.frames_sent, 1);
assert_eq!(snap.bytes_received, 300);
assert_eq!(snap.frames_received, 2);
assert_eq!(snap.messages_sent, 1);
assert_eq!(snap.reconnect_errors, 3);
assert_eq!(snap.resends_throttled, 0);
}
#[test]
fn wire_bookkeeping_reads_the_clock_only_where_a_value_is_used() {
use wacore::time::clock_reads;
let stats = wacore::stats::SessionStats::new();
let arming = clock_reads::snapshot();
stats.record_frame_sent(10);
assert_eq!(
clock_reads::since(arming).wall,
1,
"the send that arms the anchor dates it"
);
let armed = clock_reads::snapshot();
for _ in 0..16 {
stats.record_frame_sent(10);
}
assert_eq!(
clock_reads::since(armed).wall,
0,
"sends under an already-armed anchor have nothing to date"
);
let recv = clock_reads::snapshot();
stats.mark_recv_activity();
stats.record_recv_batch(100, 1);
assert_eq!(
clock_reads::since(recv).wall,
1,
"a single-frame batch is stamped once, at arrival"
);
let long_batch = clock_reads::snapshot();
stats.mark_recv_activity();
stats.record_recv_batch(100, 4);
assert_eq!(
clock_reads::since(long_batch).wall,
2,
"a long batch re-stamps on completion so a slow drain is not read as silence"
);
let rearm = clock_reads::snapshot();
stats.record_frame_sent(10);
assert_eq!(
clock_reads::since(rearm).wall,
1,
"the receive cancelled the anchor, so this send arms it again"
);
}
#[tokio::test]
async fn received_stanza_handling_reads_no_clock() {
use wacore::time::clock_reads;
let client = crate::test_utils::create_test_client().await;
let receipt = || {
to_owned_node(
&NodeBuilder::new("receipt")
.attr("id", "3EB0AABBCCDDEEFF001122")
.attr("from", "5511900000001@s.whatsapp.net")
.attr("t", "1780000000")
.build(),
)
};
client.process_decrypted_node(receipt()).await;
crate::test_utils::wait_for_outbound_tasks(&client).await;
let base = clock_reads::snapshot();
client.process_decrypted_node(receipt()).await;
let reads = clock_reads::since(base);
assert_eq!(reads.wall, 0, "receipt handling reads no wall clock");
assert_eq!(
reads.monotonic, 0,
"receipt handling reads no monotonic clock"
);
}
#[tokio::test]
async fn memory_report_display_sections_stay_aligned() {
let client = crate::test_utils::create_test_client_with_name("memory_report_sections").await;
let rendered = client.memory_report().await.to_string();
let ttl_start = rendered
.find("--- TTL-bounded caches ---")
.expect("ttl section");
let signal_start = rendered.find("--- Signal store").expect("signal section");
let ttl_block = &rendered[ttl_start..signal_start];
for name in [
"group_cache:",
"device_registry_cache:",
"recent_messages:",
"group_devices_memo:",
"dm_devices_memo:",
] {
assert!(
ttl_block.contains(name),
"{name} must render under the TTL-bounded heading, got:\n{rendered}"
);
}
for name in [
"signal_sessions:",
"signal_identities:",
"signal_sender_keys:",
] {
assert!(
rendered[signal_start..].contains(name),
"{name} must render under the Signal heading, got:\n{rendered}"
);
}
}
#[tokio::test]
async fn memory_report_on_fresh_client() {
let mut cache_config = CacheConfig::default();
cache_config.recent_messages =
crate::cache_config::CacheEntryConfig::new(Some(Duration::from_secs(300)), 64);
let client = crate::test_utils::create_test_client_with_config(
"memory_report",
Arc::new(MockHttpClient),
cache_config,
)
.await;
let report = client.memory_report().await;
assert_eq!(report.recent_messages.entries, 0);
assert_eq!(report.recent_messages.bytes, 0);
assert_eq!(report.group_distribution_locks, 0);
assert_eq!(report.group_distribution_lock_evictions, 0);
assert_eq!(report.group_distribution_lock_eviction_blocks, 0);
assert_eq!(report.signal_sessions.entries, 0);
assert_eq!(report.response_waiters, 0);
let key = ChatMessageId::new(
"559980000001@s.whatsapp.net".parse().unwrap(),
"3EB0TESTMSGID".to_string(),
);
client
.recent_messages
.insert(key, Arc::new(vec![0u8; 2048]))
.await;
let report = client.memory_report().await;
assert_eq!(report.recent_messages.entries, 1);
assert!(
report.recent_messages.bytes >= 2048,
"cached payload bytes must be attributed (got {})",
report.recent_messages.bytes
);
assert!(report.total_estimated_bytes() >= 2048);
let _ = report.to_string();
}
#[tokio::test]
async fn resource_report_composes_client_and_out_of_client_components() {
use wacore::stats::{AllocMeter, TaskInstrument};
let client = crate::test_utils::create_test_client().await;
let report = client.resource_report().await;
let mem = client.memory_report().await;
assert_eq!(
report.client.total_estimated_bytes(),
mem.total_estimated_bytes()
);
assert_eq!(
report.storage.memory_bytes.is_some(),
report.storage.pages.is_some()
);
assert!(report.transport.is_none());
assert!(report.http.is_none());
assert!(report.alloc.is_none(), "no alloc meter installed yet");
assert!(report.total_estimated_bytes() >= report.storage.total_bytes());
let _ = report.to_string();
let meter = Arc::new(AllocMeter::new());
meter.on_poll_start();
AllocMeter::on_alloc(4096);
meter.on_poll_end();
let _ = client.alloc_meter.set(meter);
let report = client.resource_report().await;
let alloc = report
.alloc
.expect("alloc snapshot folded in once installed");
assert_eq!(alloc.allocated_bytes, 4096);
assert_eq!(alloc.allocations, 1);
}
#[tokio::test]
async fn instrumented_runtime_reports_to_cpu_meter() {
use wacore::runtime::Runtime as _;
use wacore::stats::{CpuMeter, InstrumentedRuntime};
let meter = Arc::new(CpuMeter::new());
let runtime =
InstrumentedRuntime::new(Arc::new(crate::runtime_impl::TokioRuntime), meter.clone());
let (tx, rx) = oneshot::channel::<()>();
runtime
.spawn(Box::pin(async move {
let _ = tx.send(());
}))
.detach();
rx.await.expect("spawned future ran");
let after_spawn = meter.snapshot();
assert!(after_spawn.polls >= 1, "spawned future polls are metered");
runtime.spawn_blocking(Box::new(|| {})).await;
let after_blocking = meter.snapshot();
assert!(
after_blocking.polls > after_spawn.polls,
"blocking work is metered too"
);
}
#[tokio::test]
async fn status_broadcast_stanzas_are_dispatched_inline() {
use wacore_binary::builder::NodeBuilder;
let client = create_offline_sync_test_client().await;
let status = NodeBuilder::new("status")
.attr("from", "status@broadcast")
.attr("id", "INLINE-1")
.build();
assert!(
client.processes_inline(&status.as_node_ref()),
"a status@broadcast stanza must keep the read loop's arrival order"
);
let message = NodeBuilder::new("message")
.attr("from", "status@broadcast")
.attr("id", "INLINE-2")
.build();
assert!(
client.processes_inline(&message.as_node_ref()),
"the pre-existing <message> form is unchanged"
);
let newsletter_status = NodeBuilder::new("status")
.attr("from", "120363298765432100@newsletter")
.attr("id", "INLINE-3")
.build();
assert!(
!client.processes_inline(&newsletter_status.as_node_ref()),
"a newsletter <status> has no per-chat queue to order"
);
}
#[tokio::test]
async fn offline_preview_reports_status_and_call_counts() {
use wacore::types::events::{Event, EventHandler};
#[derive(Default)]
struct PreviewRecorder {
previews: std::sync::Mutex<Vec<wacore::types::events::OfflineSyncPreview>>,
}
impl EventHandler for PreviewRecorder {
fn handle_event(&self, event: Arc<Event>) {
if let Event::OfflineSyncPreview(preview) = &*event {
self.previews.lock().unwrap().push(preview.clone());
}
}
}
let client = create_offline_sync_test_client().await;
let recorder = Arc::new(PreviewRecorder::default());
client
.core
.event_bus
.subscribe_handler(recorder.clone())
.detach();
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("offline_preview")
.attr("count", "9")
.attr("message", "2")
.attr("notification", "1")
.attr("receipt", "1")
.attr("appdata", "1")
.attr("call", "1")
.attr("status", "3")
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
let previews = recorder.previews.lock().unwrap();
let preview = previews
.first()
.expect("a preview event must be dispatched");
assert_eq!(preview.total, 9);
assert_eq!(preview.messages, 2);
assert_eq!(preview.notifications, 1);
assert_eq!(preview.receipts, 1);
assert_eq!(preview.app_data_changes, 1);
assert_eq!(preview.calls, 1);
assert_eq!(preview.statuses, 3);
}
#[tokio::test]
async fn offline_preview_defaults_absent_counts_to_zero() {
use wacore::types::events::{Event, EventHandler};
#[derive(Default)]
struct PreviewRecorder {
previews: std::sync::Mutex<Vec<wacore::types::events::OfflineSyncPreview>>,
}
impl EventHandler for PreviewRecorder {
fn handle_event(&self, event: Arc<Event>) {
if let Event::OfflineSyncPreview(preview) = &*event {
self.previews.lock().unwrap().push(preview.clone());
}
}
}
let client = create_offline_sync_test_client().await;
let recorder = Arc::new(PreviewRecorder::default());
client
.core
.event_bus
.subscribe_handler(recorder.clone())
.detach();
let node = NodeBuilder::new("ib")
.children([NodeBuilder::new("offline_preview")
.attr("count", "1")
.attr("message", "1")
.build()])
.build();
client.process_node(node_to_owned_ref(node)).await;
let previews = recorder.previews.lock().unwrap();
let preview = previews
.first()
.expect("a preview event must be dispatched");
assert_eq!(preview.total, 1);
assert_eq!(preview.calls, 0);
assert_eq!(preview.statuses, 0);
}
#[test]
fn phash_waiter_sweep_drops_only_entries_that_lived_through_a_sweep() {
use crate::client::{PhashWaiter, ResponseWaiter, ResponseWaiterMap};
use futures::channel::oneshot;
let mut map = ResponseWaiterMap::default();
let waiter = |registered_epoch: u64| {
ResponseWaiter::Phash(PhashWaiter {
expected: wacore_binary::CompactString::from("hash"),
jid: "13135550100@s.whatsapp.net".parse().expect("valid jid"),
invalidate_group_cache: false,
registered_epoch,
})
};
let epoch = map.current_epoch();
map.insert("first".to_string(), waiter(epoch));
let (iq_tx, _iq_rx) = oneshot::channel();
map.insert("iq".to_string(), ResponseWaiter::Iq(iq_tx));
map.drop_expired_phash();
assert!(
map.remove("first").is_some(),
"a waiter must survive the sweep of the epoch it registered in"
);
let epoch = map.current_epoch();
map.insert("stale".to_string(), waiter(epoch));
map.drop_expired_phash();
map.drop_expired_phash();
assert!(
map.remove("stale").is_none(),
"a waiter that lived through a full sweep must be dropped"
);
assert!(
map.remove("iq").is_some(),
"the sweep must never touch IQ waiters, which have their own cleanup"
);
}
#[tokio::test]
async fn a_terminal_connect_failure_releases_a_parked_wait() {
let client = create_offline_sync_test_client().await;
client.is_running.store(true, Ordering::Relaxed);
let waiter = {
let client = Arc::clone(&client);
tokio::spawn(async move { client.await_connection().await })
};
crate::test_utils::poll_until("the waiter to park on the notifier", || {
client.session_state_notifier.total_listeners() >= 1
})
.await;
let failure = NodeBuilder::new("failure").attr("reason", "403").build();
client.handle_connect_failure(&failure.as_node_ref()).await;
assert!(
client.is_terminal(),
"the failure decided the session is over"
);
assert!(
!tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("and the wait must end on that decision")
.expect("the waiter should not panic"),
"reporting that no connection arrived"
);
}