Add comprehensive tests across three layers: - RPC framing: empty payloads, max boundary, truncated frames, multi-frame buffers, all status codes, all method ID ranges, payload-too-large for response/push - SDK: event broadcast send/receive, multiple subscribers, clone preservation, conversation upsert, missing conversation, message ID roundtrip, member keys - Server domain: auth session validation/expiry, channel creation/symmetry/validation, delivery peek/ack/sequence ordering/fetch-limited, key package upload/fetch/validation, hybrid key batch fetch, size boundary tests - CI: MSRV (1.75) check job, macOS cross-platform build check
230 lines
6.5 KiB
Rust
230 lines
6.5 KiB
Rust
//! Client event system — real-time notifications from the SDK.
|
|
|
|
/// Events emitted by the SDK to the UI layer.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ClientEvent {
|
|
/// Successfully connected to the server.
|
|
Connected,
|
|
|
|
/// Disconnected from the server.
|
|
Disconnected { reason: String },
|
|
|
|
/// Connection lost, attempting to reconnect.
|
|
Reconnecting { attempt: u32 },
|
|
|
|
/// Registration succeeded.
|
|
Registered { username: String },
|
|
|
|
/// Login succeeded.
|
|
LoggedIn { username: String },
|
|
|
|
/// Logged out.
|
|
LoggedOut { username: String },
|
|
|
|
/// Authentication succeeded.
|
|
Authenticated { username: String },
|
|
|
|
/// A new message was received in a conversation.
|
|
MessageReceived {
|
|
conversation_id: [u8; 16],
|
|
sender_key: Vec<u8>,
|
|
sender_name: Option<String>,
|
|
body: String,
|
|
timestamp_ms: u64,
|
|
},
|
|
|
|
/// A message was sent successfully.
|
|
MessageSent {
|
|
conversation_id: [u8; 16],
|
|
seq: u64,
|
|
},
|
|
|
|
/// A new conversation was created or discovered.
|
|
ConversationCreated {
|
|
conversation_id: [u8; 16],
|
|
display_name: String,
|
|
},
|
|
|
|
/// A member was added to a group conversation.
|
|
MemberAdded {
|
|
conversation_id: [u8; 16],
|
|
member_key: Vec<u8>,
|
|
},
|
|
|
|
/// A member was removed from a group conversation.
|
|
MemberRemoved {
|
|
conversation_id: [u8; 16],
|
|
member_key: Vec<u8>,
|
|
},
|
|
|
|
/// Server-push event received.
|
|
PushEvent {
|
|
event_type: u16,
|
|
payload: Vec<u8>,
|
|
},
|
|
|
|
/// A message was queued in the offline outbox (send failed or disconnected).
|
|
MessageQueued {
|
|
outbox_id: i64,
|
|
conversation_id: [u8; 16],
|
|
},
|
|
|
|
/// Outbox flush completed after reconnect.
|
|
OutboxFlushed {
|
|
sent: usize,
|
|
failed: usize,
|
|
},
|
|
|
|
/// Gap detected in message sequence numbers.
|
|
MessageGap {
|
|
conversation_id: [u8; 16],
|
|
expected_seq: u64,
|
|
received_seq: u64,
|
|
},
|
|
|
|
/// An error occurred in the background.
|
|
Error { message: String },
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::sync::broadcast;
|
|
|
|
#[test]
|
|
fn event_broadcast_send_receive() {
|
|
let (tx, mut rx) = broadcast::channel::<ClientEvent>(16);
|
|
|
|
tx.send(ClientEvent::Connected).unwrap();
|
|
tx.send(ClientEvent::Disconnected {
|
|
reason: "test".into(),
|
|
})
|
|
.unwrap();
|
|
|
|
let e1 = rx.try_recv().unwrap();
|
|
assert!(matches!(e1, ClientEvent::Connected));
|
|
|
|
let e2 = rx.try_recv().unwrap();
|
|
assert!(matches!(e2, ClientEvent::Disconnected { reason } if reason == "test"));
|
|
}
|
|
|
|
#[test]
|
|
fn event_broadcast_multiple_subscribers() {
|
|
let (tx, mut rx1) = broadcast::channel::<ClientEvent>(16);
|
|
let mut rx2 = tx.subscribe();
|
|
|
|
tx.send(ClientEvent::Registered {
|
|
username: "alice".into(),
|
|
})
|
|
.unwrap();
|
|
|
|
let e1 = rx1.try_recv().unwrap();
|
|
let e2 = rx2.try_recv().unwrap();
|
|
|
|
assert!(matches!(e1, ClientEvent::Registered { username } if username == "alice"));
|
|
assert!(matches!(e2, ClientEvent::Registered { username } if username == "alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn event_no_subscribers_does_not_panic() {
|
|
let (tx, _) = broadcast::channel::<ClientEvent>(16);
|
|
// Send with no active receiver — should return Err but not panic.
|
|
let result = tx.send(ClientEvent::Connected);
|
|
assert!(result.is_err()); // no receivers
|
|
}
|
|
|
|
#[test]
|
|
fn event_clone_preserves_data() {
|
|
let event = ClientEvent::MessageReceived {
|
|
conversation_id: [1u8; 16],
|
|
sender_key: vec![2u8; 32],
|
|
sender_name: Some("bob".into()),
|
|
body: "hello world".into(),
|
|
timestamp_ms: 12345,
|
|
};
|
|
|
|
let cloned = event.clone();
|
|
match cloned {
|
|
ClientEvent::MessageReceived {
|
|
conversation_id,
|
|
sender_key,
|
|
sender_name,
|
|
body,
|
|
timestamp_ms,
|
|
} => {
|
|
assert_eq!(conversation_id, [1u8; 16]);
|
|
assert_eq!(sender_key, vec![2u8; 32]);
|
|
assert_eq!(sender_name, Some("bob".to_string()));
|
|
assert_eq!(body, "hello world");
|
|
assert_eq!(timestamp_ms, 12345);
|
|
}
|
|
_ => panic!("wrong variant after clone"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn event_debug_format() {
|
|
let event = ClientEvent::Error {
|
|
message: "something went wrong".into(),
|
|
};
|
|
let dbg = format!("{event:?}");
|
|
assert!(dbg.contains("something went wrong"));
|
|
}
|
|
|
|
#[test]
|
|
fn all_event_variants_are_clone() {
|
|
// Verify all variants can be cloned without issue.
|
|
let events: Vec<ClientEvent> = vec![
|
|
ClientEvent::Connected,
|
|
ClientEvent::Disconnected { reason: "r".into() },
|
|
ClientEvent::Reconnecting { attempt: 1 },
|
|
ClientEvent::Registered { username: "u".into() },
|
|
ClientEvent::LoggedIn { username: "u".into() },
|
|
ClientEvent::LoggedOut { username: "u".into() },
|
|
ClientEvent::Authenticated { username: "u".into() },
|
|
ClientEvent::MessageReceived {
|
|
conversation_id: [0; 16],
|
|
sender_key: vec![],
|
|
sender_name: None,
|
|
body: "b".into(),
|
|
timestamp_ms: 0,
|
|
},
|
|
ClientEvent::MessageSent {
|
|
conversation_id: [0; 16],
|
|
seq: 0,
|
|
},
|
|
ClientEvent::ConversationCreated {
|
|
conversation_id: [0; 16],
|
|
display_name: "d".into(),
|
|
},
|
|
ClientEvent::MemberAdded {
|
|
conversation_id: [0; 16],
|
|
member_key: vec![],
|
|
},
|
|
ClientEvent::MemberRemoved {
|
|
conversation_id: [0; 16],
|
|
member_key: vec![],
|
|
},
|
|
ClientEvent::PushEvent {
|
|
event_type: 0,
|
|
payload: vec![],
|
|
},
|
|
ClientEvent::MessageQueued {
|
|
outbox_id: 0,
|
|
conversation_id: [0; 16],
|
|
},
|
|
ClientEvent::OutboxFlushed { sent: 0, failed: 0 },
|
|
ClientEvent::MessageGap {
|
|
conversation_id: [0; 16],
|
|
expected_seq: 0,
|
|
received_seq: 1,
|
|
},
|
|
ClientEvent::Error { message: "e".into() },
|
|
];
|
|
for event in &events {
|
|
let _ = event.clone();
|
|
}
|
|
assert_eq!(events.len(), 17);
|
|
}
|
|
}
|