From e5329ee8e53286e431d5459df6ec80fa5d8bc9e6 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 13:31:25 +0100 Subject: [PATCH] test: add unit tests for ConversationStore CRUD, outbox, and ConversationId --- crates/quicproquo-sdk/Cargo.toml | 1 + crates/quicproquo-sdk/src/conversation.rs | 182 ++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/crates/quicproquo-sdk/Cargo.toml b/crates/quicproquo-sdk/Cargo.toml index 9a81e61..fa2fd8e 100644 --- a/crates/quicproquo-sdk/Cargo.toml +++ b/crates/quicproquo-sdk/Cargo.toml @@ -31,6 +31,7 @@ prost = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +tempfile = "3" [lints] workspace = true diff --git a/crates/quicproquo-sdk/src/conversation.rs b/crates/quicproquo-sdk/src/conversation.rs index 9e530ff..c668936 100644 --- a/crates/quicproquo-sdk/src/conversation.rs +++ b/crates/quicproquo-sdk/src/conversation.rs @@ -559,3 +559,185 @@ fn row_to_message( is_outgoing: is_outgoing != 0, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn open_test_store() -> (tempfile::TempDir, ConversationStore) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let store = ConversationStore::open(&db_path, None).unwrap(); + (dir, store) + } + + fn make_group_conv(name: &str, activity_ms: u64) -> Conversation { + Conversation { + id: ConversationId::from_group_name(name), + kind: ConversationKind::Group { name: name.to_string() }, + display_name: format!("#{name}"), + mls_group_blob: None, + keystore_blob: None, + member_keys: vec![vec![1, 2, 3]], + unread_count: 0, + last_activity_ms: activity_ms, + created_at_ms: 1000, + is_hybrid: false, + last_seen_seq: 0, + } + } + + fn make_dm_conv(peer_key: &[u8], peer_name: Option<&str>) -> Conversation { + let mut id_bytes = [0u8; 16]; + id_bytes[..peer_key.len().min(16)].copy_from_slice(&peer_key[..peer_key.len().min(16)]); + Conversation { + id: ConversationId(id_bytes), + kind: ConversationKind::Dm { + peer_key: peer_key.to_vec(), + peer_username: peer_name.map(|s| s.to_string()), + }, + display_name: peer_name.unwrap_or("unknown").to_string(), + mls_group_blob: None, + keystore_blob: None, + member_keys: vec![peer_key.to_vec()], + unread_count: 0, + last_activity_ms: 2000, + created_at_ms: 1000, + is_hybrid: false, + last_seen_seq: 0, + } + } + + #[test] + fn save_and_load_group() { + let (_dir, store) = open_test_store(); + let conv = make_group_conv("engineering", 5000); + store.save_conversation(&conv).unwrap(); + + let loaded = store.load_conversation(&conv.id).unwrap().unwrap(); + assert_eq!(loaded.display_name, "#engineering"); + assert_eq!(loaded.last_activity_ms, 5000); + match &loaded.kind { + ConversationKind::Group { name } => assert_eq!(name, "engineering"), + _ => panic!("expected Group kind"), + } + } + + #[test] + fn save_and_load_dm() { + let (_dir, store) = open_test_store(); + let peer_key = vec![10u8; 32]; + let conv = make_dm_conv(&peer_key, Some("alice")); + store.save_conversation(&conv).unwrap(); + + let loaded = store.load_conversation(&conv.id).unwrap().unwrap(); + assert_eq!(loaded.display_name, "alice"); + match &loaded.kind { + ConversationKind::Dm { peer_key: pk, peer_username } => { + assert_eq!(pk, &peer_key); + assert_eq!(peer_username.as_deref(), Some("alice")); + } + _ => panic!("expected Dm kind"), + } + } + + #[test] + fn find_dm_by_peer() { + let (_dir, store) = open_test_store(); + let peer_key = vec![20u8; 32]; + let conv = make_dm_conv(&peer_key, Some("bob")); + store.save_conversation(&conv).unwrap(); + + let found = store.find_dm_by_peer(&peer_key).unwrap().unwrap(); + assert_eq!(found.id, conv.id); + + let missing = store.find_dm_by_peer(&[99u8; 32]).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn list_conversations_ordering() { + let (_dir, store) = open_test_store(); + let c1 = make_group_conv("old-group", 1000); + let c2 = make_group_conv("new-group", 3000); + let c3 = make_group_conv("mid-group", 2000); + store.save_conversation(&c1).unwrap(); + store.save_conversation(&c2).unwrap(); + store.save_conversation(&c3).unwrap(); + + let list = store.list_conversations().unwrap(); + assert_eq!(list.len(), 3); + // Most recent activity first + assert_eq!(list[0].last_activity_ms, 3000); + assert_eq!(list[1].last_activity_ms, 2000); + assert_eq!(list[2].last_activity_ms, 1000); + } + + #[test] + fn save_and_load_messages() { + let (_dir, store) = open_test_store(); + let conv = make_group_conv("chat", 1000); + store.save_conversation(&conv).unwrap(); + + for i in 0..5 { + store.save_message(&StoredMessage { + conversation_id: conv.id.clone(), + message_id: None, + sender_key: vec![1, 2, 3], + sender_name: Some("alice".to_string()), + body: format!("message {i}"), + msg_type: "chat".to_string(), + ref_msg_id: None, + timestamp_ms: 1000 + i as u64, + is_outgoing: i % 2 == 0, + }).unwrap(); + } + + let msgs = store.load_recent_messages(&conv.id, 3).unwrap(); + assert_eq!(msgs.len(), 3); + // Should be in chronological order (reversed from DESC) + assert_eq!(msgs[0].body, "message 2"); + assert_eq!(msgs[1].body, "message 3"); + assert_eq!(msgs[2].body, "message 4"); + } + + #[test] + fn outbox_enqueue_and_mark_sent() { + let (_dir, store) = open_test_store(); + let conv_id = ConversationId([1u8; 16]); + let recipient = vec![5u8; 32]; + let payload = b"encrypted-payload"; + + store.enqueue_outbox(&conv_id, &recipient, payload).unwrap(); + store.enqueue_outbox(&conv_id, &recipient, b"second").unwrap(); + + let pending = store.load_pending_outbox().unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(store.count_pending_outbox().unwrap(), 2); + + store.mark_outbox_sent(pending[0].id).unwrap(); + assert_eq!(store.count_pending_outbox().unwrap(), 1); + + let remaining = store.load_pending_outbox().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].payload, b"second"); + } + + #[test] + fn conversation_id_from_group_name_determinism() { + let a = ConversationId::from_group_name("test-group"); + let b = ConversationId::from_group_name("test-group"); + assert_eq!(a, b); + + let c = ConversationId::from_group_name("other-group"); + assert_ne!(a, c); + } + + #[test] + fn conversation_id_from_slice_wrong_length() { + assert!(ConversationId::from_slice(&[0u8; 15]).is_none()); + assert!(ConversationId::from_slice(&[0u8; 17]).is_none()); + assert!(ConversationId::from_slice(&[]).is_none()); + assert!(ConversationId::from_slice(&[0u8; 16]).is_some()); + } +}