use crate::types::{AppId, HopCount, LamportClock, Timestamp, UserId, TTL};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageId(Uuid);
impl MessageId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(id: &str) -> crate::Result<Self> {
let uuid = Uuid::parse_str(id)
.map_err(|e| crate::Error::InvalidMessage(format!("Invalid message id: {}", e)))?;
Ok(Self::from_uuid(uuid))
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn as_str(&self) -> String {
self.0.to_string()
}
pub fn as_bytes(&self) -> [u8; 16] {
*self.0.as_bytes()
}
pub fn from_bytes(bytes: [u8; 16]) -> Self {
Self(Uuid::from_bytes(bytes))
}
}
impl Default for MessageId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for MessageId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ContentType {
#[default]
Text,
Image,
Video,
Audio,
VoiceNote,
VideoNote,
File,
FileChunk,
Poll,
}
impl ContentType {
pub const ALL: [Self; 9] = [
Self::Text,
Self::Image,
Self::Video,
Self::Audio,
Self::VoiceNote,
Self::VideoNote,
Self::File,
Self::FileChunk,
Self::Poll,
];
pub fn is_media(&self) -> bool {
!matches!(self, Self::Text | Self::Poll)
}
pub const fn as_wire_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Image => "image",
Self::Video => "video",
Self::Audio => "audio",
Self::VoiceNote => "voice_note",
Self::VideoNote => "video_note",
Self::File => "file",
Self::FileChunk => "file_chunk",
Self::Poll => "poll",
}
}
pub fn parse(s: &str) -> Self {
match s {
"text" => Self::Text,
"image" => Self::Image,
"video" => Self::Video,
"audio" => Self::Audio,
"voice_note" => Self::VoiceNote,
"video_note" => Self::VideoNote,
"file" => Self::File,
"file_chunk" => Self::FileChunk,
"poll" => Self::Poll,
_ => Self::File,
}
}
}
const _: () = {
const fn listed_at_own_index(ct: ContentType) -> bool {
match ct {
ContentType::Text => matches!(ContentType::ALL[0], ContentType::Text),
ContentType::Image => matches!(ContentType::ALL[1], ContentType::Image),
ContentType::Video => matches!(ContentType::ALL[2], ContentType::Video),
ContentType::Audio => matches!(ContentType::ALL[3], ContentType::Audio),
ContentType::VoiceNote => matches!(ContentType::ALL[4], ContentType::VoiceNote),
ContentType::VideoNote => matches!(ContentType::ALL[5], ContentType::VideoNote),
ContentType::File => matches!(ContentType::ALL[6], ContentType::File),
ContentType::FileChunk => matches!(ContentType::ALL[7], ContentType::FileChunk),
ContentType::Poll => matches!(ContentType::ALL[8], ContentType::Poll),
}
}
let mut i = 0;
while i < ContentType::ALL.len() {
assert!(listed_at_own_index(ContentType::ALL[i]));
i += 1;
}
};
impl Serialize for ContentType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_wire_str())
}
}
impl<'de> Deserialize<'de> for ContentType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ContentTypeVisitor;
impl serde::de::Visitor<'_> for ContentTypeVisitor {
type Value = ContentType;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a content type string")
}
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<ContentType, E> {
Ok(ContentType::parse(s))
}
}
deserializer.deserialize_str(ContentTypeVisitor)
}
}
impl fmt::Display for ContentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_wire_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMetadata {
pub mime_type: String,
pub file_name: String,
pub file_size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iv: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ciphertext_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sticker_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sticker_remote_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sticker_kind: Option<String>,
}
impl MediaMetadata {
pub fn has_secrets(&self) -> bool {
self.encryption_key.is_some() || self.iv.is_some()
}
pub fn without_secrets(&self) -> Self {
Self {
encryption_key: None,
iv: None,
..self.clone()
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ForwardInfo {
pub original_sender: UserId,
pub original_message_id: MessageId,
pub original_timestamp: Timestamp,
pub forward_count: u32,
}
impl ForwardInfo {
pub fn from_message(message: &Message) -> Self {
match &message.forwarded_from {
Some(existing) => ForwardInfo {
original_sender: existing.original_sender.clone(),
original_message_id: existing.original_message_id.clone(),
original_timestamp: existing.original_timestamp,
forward_count: existing.forward_count.saturating_add(1),
},
None => ForwardInfo {
original_sender: message.sender.clone(),
original_message_id: message.id.clone(),
original_timestamp: message.timestamp,
forward_count: 1,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReplyContext {
pub sender: UserId,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_media_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_content_type: Option<String>,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum MessagePriority {
#[serde(alias = "Low")]
Low,
#[default]
#[serde(alias = "Medium")]
Medium,
#[serde(alias = "High")]
High,
#[serde(alias = "Critical")]
Critical,
}
impl MessagePriority {
pub fn score(&self) -> u8 {
match self {
Self::Low => 1,
Self::Medium => 2,
Self::High => 3,
Self::Critical => 4,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "Low",
Self::Medium => "Medium",
Self::High => "High",
Self::Critical => "Critical",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WireCodec {
#[default]
Json,
BinaryV1,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: MessageId,
pub sender: UserId,
pub recipient: UserId,
pub app_id: AppId,
pub priority: MessagePriority,
pub ttl: TTL,
pub hop_count: HopCount,
pub timestamp: Timestamp,
#[serde(default)]
pub lamport_clock: LamportClock,
#[serde(default)]
pub content_type: ContentType,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_content: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_metadata: Option<MediaMetadata>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(default = "default_requires_ack")]
pub requires_ack: bool,
#[serde(default)]
pub reply_to_msg: Option<MessageId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forwarded_from: Option<ForwardInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_context: Option<ReplyContext>,
#[serde(skip)]
transport_peer_id: Option<String>,
#[serde(skip)]
wire_codec: WireCodec,
}
fn default_requires_ack() -> bool {
true
}
impl Message {
pub fn new(
sender: UserId,
recipient: UserId,
app_id: AppId,
content: impl Into<String>,
) -> Self {
Self {
id: MessageId::new(),
sender,
recipient,
app_id,
priority: MessagePriority::default(),
ttl: TTL::default(),
hop_count: HopCount::new(),
timestamp: Timestamp::now(),
lamport_clock: LamportClock::default(),
content_type: ContentType::default(),
content: content.into(),
binary_content: None,
media_metadata: None,
metadata: HashMap::new(),
requires_ack: true,
reply_to_msg: None,
forwarded_from: None,
reply_context: None,
transport_peer_id: None,
wire_codec: WireCodec::Json,
}
}
pub fn builder(sender: UserId, recipient: UserId, app_id: AppId) -> MessageBuilder {
MessageBuilder::new(sender, recipient, app_id)
}
pub fn increment_hop(&mut self) -> crate::Result<()> {
self.hop_count = self
.hop_count
.increment()
.ok_or(crate::Error::InvalidHopCount(u8::MAX))?;
Ok(())
}
pub fn decrement_ttl(&mut self) -> crate::Result<()> {
self.ttl = self.ttl.decrement().ok_or(crate::Error::InvalidTTL(0))?;
Ok(())
}
pub fn is_ttl_exhausted(&self) -> bool {
self.ttl.is_exhausted()
}
pub fn to_json(&self) -> crate::Result<String> {
serde_json::to_string(self).map_err(|e| crate::Error::SerializationError(e.to_string()))
}
pub fn from_json(json: &str) -> crate::Result<Self> {
serde_json::from_str(json).map_err(|e| crate::Error::DeserializationError(e.to_string()))
}
#[doc(hidden)]
pub fn set_transport_peer_id(&mut self, peer_id: String) -> crate::Result<()> {
if peer_id.is_empty() {
return Err(crate::Error::InvalidMessage(
"transport_peer_id must not be empty".to_string(),
));
}
self.transport_peer_id = Some(peer_id);
Ok(())
}
pub fn transport_peer_id(&self) -> Option<&str> {
self.transport_peer_id.as_deref()
}
#[doc(hidden)]
pub fn set_wire_codec(&mut self, codec: WireCodec) {
self.wire_codec = codec;
}
pub fn wire_codec(&self) -> WireCodec {
self.wire_codec
}
pub fn to_bytes(&self) -> crate::Result<Vec<u8>> {
serde_json::to_vec(self).map_err(|e| crate::Error::SerializationError(e.to_string()))
}
pub fn from_bytes(bytes: &[u8]) -> crate::Result<Self> {
serde_json::from_slice(bytes).map_err(|e| crate::Error::DeserializationError(e.to_string()))
}
pub fn to_wire_v1_bytes(&self) -> crate::Result<Vec<u8>> {
let media_metadata_json =
match &self.media_metadata {
Some(m) => Some(serde_json::to_vec(m).map_err(|e| {
crate::Error::SerializationError(format!("media_metadata: {e}"))
})?),
None => None,
};
let forwarded_from_json =
match &self.forwarded_from {
Some(f) => Some(serde_json::to_vec(f).map_err(|e| {
crate::Error::SerializationError(format!("forwarded_from: {e}"))
})?),
None => None,
};
let mut metadata: Vec<(String, String)> = self
.metadata
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
metadata.sort_by(|a, b| a.0.cmp(&b.0));
let (content, mut ext) = match crate::wire::split_b64_tail(&self.content) {
Some((head, raw)) => (head.to_string(), vec![(crate::wire::EXT_TAG_B64_TAIL, raw)]),
None => (self.content.clone(), Vec::new()),
};
if let Some(rc) = &self.reply_context {
ext.push((
crate::wire::EXT_TAG_REPLY_CONTEXT,
serde_json::to_vec(rc)
.map_err(|e| crate::Error::SerializationError(format!("reply_context: {e}")))?,
));
}
let wire = crate::wire::WireMessageV1 {
id: self.id.as_bytes(),
sender: self.sender.as_str().to_string(),
recipient: self.recipient.as_str().to_string(),
app_id: self.app_id.as_str().to_string(),
priority: crate::wire::priority_to_u8(self.priority),
ttl: self.ttl.value(),
hop_count: self.hop_count.value(),
timestamp: self.timestamp.as_millis(),
lamport_clock: self.lamport_clock.value(),
content_type: crate::wire::content_type_to_u8(self.content_type),
content,
binary_content: self.binary_content.clone(),
media_metadata_json,
metadata,
requires_ack: self.requires_ack,
reply_to_msg: self.reply_to_msg.as_ref().map(|m| m.as_bytes()),
forwarded_from_json,
ext,
};
crate::wire::encode(&wire)
}
pub fn from_wire_v1_bytes(data: &[u8]) -> crate::Result<Self> {
let wire = crate::wire::decode(data)?;
let mut content = wire.content;
if let Some((_, raw)) = wire
.ext
.iter()
.find(|(tag, _)| *tag == crate::wire::EXT_TAG_B64_TAIL)
{
content.push_str(&crate::wire::encode_b64_tail(raw));
}
let media_metadata =
match wire.media_metadata_json {
Some(bytes) => Some(serde_json::from_slice(&bytes).map_err(|e| {
crate::Error::DeserializationError(format!("media_metadata: {e}"))
})?),
None => None,
};
let forwarded_from =
match wire.forwarded_from_json {
Some(bytes) => Some(serde_json::from_slice(&bytes).map_err(|e| {
crate::Error::DeserializationError(format!("forwarded_from: {e}"))
})?),
None => None,
};
let reply_context =
match wire
.ext
.iter()
.find(|(tag, _)| *tag == crate::wire::EXT_TAG_REPLY_CONTEXT)
{
Some((_, bytes)) => Some(serde_json::from_slice(bytes).map_err(|e| {
crate::Error::DeserializationError(format!("reply_context: {e}"))
})?),
None => None,
};
Ok(Self {
id: MessageId::from_bytes(wire.id),
sender: UserId::new(wire.sender)?,
recipient: UserId::new(wire.recipient)?,
app_id: AppId::new(wire.app_id)?,
priority: crate::wire::priority_from_u8(wire.priority),
ttl: TTL::from_value(wire.ttl),
hop_count: HopCount::from_value(wire.hop_count),
timestamp: Timestamp::from_millis(wire.timestamp),
lamport_clock: LamportClock::from_value(wire.lamport_clock),
content_type: crate::wire::content_type_from_u8(wire.content_type),
content,
binary_content: wire.binary_content,
media_metadata,
metadata: wire.metadata.into_iter().collect(),
requires_ack: wire.requires_ack,
reply_to_msg: wire.reply_to_msg.map(MessageId::from_bytes),
forwarded_from,
reply_context,
transport_peer_id: None,
wire_codec: WireCodec::Json,
})
}
}
pub struct MessageBuilder {
sender: UserId,
recipient: UserId,
app_id: AppId,
content_type: ContentType,
content: String,
media_metadata: Option<MediaMetadata>,
priority: MessagePriority,
ttl: TTL,
lamport_clock: LamportClock,
metadata: HashMap<String, String>,
requires_ack: bool,
reply_to_msg: Option<MessageId>,
forwarded_from: Option<ForwardInfo>,
reply_context: Option<ReplyContext>,
}
impl MessageBuilder {
pub fn new(sender: UserId, recipient: UserId, app_id: AppId) -> Self {
Self {
sender,
recipient,
app_id,
content_type: ContentType::default(),
content: String::new(),
media_metadata: None,
priority: MessagePriority::default(),
ttl: TTL::default(),
lamport_clock: LamportClock::default(),
metadata: HashMap::new(),
requires_ack: true,
reply_to_msg: None,
forwarded_from: None,
reply_context: None,
}
}
pub fn content_type(mut self, content_type: ContentType) -> Self {
self.content_type = content_type;
self
}
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
pub fn media_metadata(mut self, meta: MediaMetadata) -> Self {
self.media_metadata = Some(meta);
self
}
pub fn priority(mut self, priority: MessagePriority) -> Self {
self.priority = priority;
self
}
pub fn ttl(mut self, ttl: TTL) -> Self {
self.ttl = ttl;
self
}
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn requires_ack(mut self, requires_ack: bool) -> Self {
self.requires_ack = requires_ack;
self
}
pub fn reply_to_msg(mut self, reply_to: MessageId) -> Self {
self.reply_to_msg = Some(reply_to);
self
}
pub fn forwarded_from(mut self, info: ForwardInfo) -> Self {
self.forwarded_from = Some(info);
self
}
pub fn reply_context(mut self, ctx: ReplyContext) -> Self {
self.reply_context = Some(ctx);
self
}
pub fn lamport_clock(mut self, clock: LamportClock) -> Self {
self.lamport_clock = clock;
self
}
pub fn build(self) -> Message {
Message {
id: MessageId::new(),
sender: self.sender,
recipient: self.recipient,
app_id: self.app_id,
priority: self.priority,
ttl: self.ttl,
hop_count: HopCount::new(),
timestamp: Timestamp::now(),
lamport_clock: self.lamport_clock,
content_type: self.content_type,
content: self.content,
binary_content: None,
media_metadata: self.media_metadata,
metadata: self.metadata,
requires_ack: self.requires_ack,
reply_to_msg: self.reply_to_msg,
forwarded_from: self.forwarded_from,
reply_context: self.reply_context,
transport_peer_id: None,
wire_codec: WireCodec::Json,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_message() -> Message {
Message::new(
UserId::new("alice").unwrap(),
UserId::new("bob").unwrap(),
AppId::new("test-app").unwrap(),
"Hello, Bob!",
)
}
#[test]
fn test_message_creation() {
let msg = create_test_message();
assert_eq!(msg.sender.as_str(), "alice");
assert_eq!(msg.recipient.as_str(), "bob");
assert_eq!(msg.content, "Hello, Bob!");
assert_eq!(msg.priority, MessagePriority::Medium);
assert_eq!(msg.hop_count.value(), 0);
}
#[test]
fn test_message_builder() {
let msg = Message::builder(
UserId::new("alice").unwrap(),
UserId::new("bob").unwrap(),
AppId::new("test-app").unwrap(),
)
.content("Test message")
.priority(MessagePriority::High)
.metadata("key1", "value1")
.requires_ack(false)
.build();
assert_eq!(msg.content, "Test message");
assert_eq!(msg.priority, MessagePriority::High);
assert_eq!(msg.metadata.get("key1").unwrap(), "value1");
assert!(!msg.requires_ack);
}
#[test]
fn reply_context_json_round_trip_and_default() {
let mut msg = create_test_message();
msg.reply_context = Some(ReplyContext {
sender: UserId::new("carol").unwrap(),
text: "quoted".into(),
timestamp: Some(Timestamp::from_millis(1_700_000_000_000)),
reply_media_label: None,
reply_content_type: Some("text".into()),
});
let decoded = Message::from_bytes(&msg.to_bytes().unwrap()).unwrap();
assert_eq!(decoded.reply_context, msg.reply_context);
let plain = create_test_message();
let json = serde_json::to_string(&plain).unwrap();
assert!(!json.contains("reply_context"));
let decoded = Message::from_bytes(json.as_bytes()).unwrap();
assert!(decoded.reply_context.is_none());
}
#[test]
fn media_metadata_without_secrets_clears_only_key_material() {
let meta = MediaMetadata {
mime_type: "image/jpeg".into(),
file_name: "x.jpg".into(),
file_size: 10,
duration_ms: None,
width: Some(1),
height: Some(1),
thumbnail_base64: None,
media_id: Some("m1".into()),
download_url: Some("https://cdn.example/m1".into()),
thumbnail_url: Some("https://cdn.example/m1-t".into()),
encryption_key: Some("a2V5".into()),
iv: Some("aXY=".into()),
ciphertext_hash: Some("aGFzaA==".into()),
sticker_provider: Some("prov".into()),
sticker_remote_id: Some("rid".into()),
sticker_kind: Some("static".into()),
};
assert!(meta.has_secrets());
let clean = meta.without_secrets();
assert!(!clean.has_secrets());
assert!(clean.encryption_key.is_none());
assert!(clean.iv.is_none());
assert_eq!(clean.media_id, meta.media_id);
assert_eq!(clean.download_url, meta.download_url);
assert_eq!(clean.thumbnail_url, meta.thumbnail_url);
assert_eq!(clean.ciphertext_hash, meta.ciphertext_hash);
assert_eq!(clean.sticker_provider, meta.sticker_provider);
assert_eq!(clean.sticker_remote_id, meta.sticker_remote_id);
assert_eq!(clean.sticker_kind, meta.sticker_kind);
assert_eq!(clean.file_name, meta.file_name);
}
#[test]
fn message_json_ignores_unknown_fields() {
let mut json = serde_json::to_value(create_test_message()).unwrap();
json["some_future_field"] = serde_json::Value::String("x".into());
let bytes = serde_json::to_vec(&json).unwrap();
assert!(Message::from_bytes(&bytes).is_ok());
}
#[test]
fn test_message_hop_operations() {
let mut msg = create_test_message();
assert_eq!(msg.hop_count.value(), 0);
msg.increment_hop().unwrap();
assert_eq!(msg.hop_count.value(), 1);
}
#[test]
fn test_message_ttl_operations() {
let mut msg = create_test_message();
let initial_ttl = msg.ttl.value();
msg.decrement_ttl().unwrap();
assert_eq!(msg.ttl.value(), initial_ttl - 1);
}
#[test]
fn test_message_serialization() {
let msg = create_test_message();
let json = msg.to_json().unwrap();
let deserialized = Message::from_json(&json).unwrap();
assert_eq!(msg.id, deserialized.id);
assert_eq!(msg.sender, deserialized.sender);
assert_eq!(msg.content, deserialized.content);
let bytes = msg.to_bytes().unwrap();
let deserialized = Message::from_bytes(&bytes).unwrap();
assert_eq!(msg.id, deserialized.id);
}
#[test]
fn test_message_priority_score() {
assert_eq!(MessagePriority::Low.score(), 1);
assert_eq!(MessagePriority::Medium.score(), 2);
assert_eq!(MessagePriority::High.score(), 3);
assert_eq!(MessagePriority::Critical.score(), 4);
}
#[test]
fn test_forward_info_serialization() {
use crate::types::Timestamp;
let info = ForwardInfo {
original_sender: UserId::new("alice").unwrap(),
original_message_id: MessageId::new(),
original_timestamp: Timestamp::now(),
forward_count: 1,
};
let msg = Message::builder(
UserId::new("bob").unwrap(),
UserId::new("charlie").unwrap(),
AppId::new("test-app").unwrap(),
)
.content("Forwarded message")
.forwarded_from(info.clone())
.build();
assert!(msg.forwarded_from.is_some());
let fwd = msg.forwarded_from.as_ref().unwrap();
assert_eq!(fwd.original_sender.as_str(), "alice");
assert_eq!(fwd.forward_count, 1);
let json = msg.to_json().unwrap();
let deserialized = Message::from_json(&json).unwrap();
let fwd2 = deserialized.forwarded_from.as_ref().unwrap();
assert_eq!(fwd2.original_sender.as_str(), "alice");
assert_eq!(fwd2.forward_count, 1);
}
#[test]
fn test_backward_compat_no_forwarded_from() {
let msg = create_test_message();
let json = msg.to_json().unwrap();
assert!(!json.contains("forwarded_from"));
let deserialized = Message::from_json(&json).unwrap();
assert!(deserialized.forwarded_from.is_none());
}
#[test]
fn test_forward_chain_increment() {
let original_msg = Message::builder(
UserId::new("alice").unwrap(),
UserId::new("bob").unwrap(),
AppId::new("test-app").unwrap(),
)
.content("Hello from Alice")
.build();
let first_forward = ForwardInfo::from_message(&original_msg);
assert_eq!(first_forward.original_sender.as_str(), "alice");
assert_eq!(first_forward.original_message_id, original_msg.id);
assert_eq!(first_forward.forward_count, 1);
let forwarded_msg = Message::builder(
UserId::new("bob").unwrap(),
UserId::new("charlie").unwrap(),
AppId::new("test-app").unwrap(),
)
.content("Hello from Alice")
.forwarded_from(first_forward)
.build();
let second_forward = ForwardInfo::from_message(&forwarded_msg);
assert_eq!(second_forward.original_sender.as_str(), "alice");
assert_eq!(second_forward.forward_count, 2);
assert_eq!(second_forward.original_message_id, original_msg.id);
}
#[test]
fn test_forward_count_saturates_at_max() {
use crate::types::Timestamp;
let saturated = ForwardInfo {
original_sender: UserId::new("alice").unwrap(),
original_message_id: MessageId::new(),
original_timestamp: Timestamp::now(),
forward_count: u32::MAX,
};
let msg = Message::builder(
UserId::new("bob").unwrap(),
UserId::new("charlie").unwrap(),
AppId::new("test-app").unwrap(),
)
.content("Hello")
.forwarded_from(saturated)
.build();
let next = ForwardInfo::from_message(&msg);
assert_eq!(next.forward_count, u32::MAX);
}
#[test]
fn content_type_json_round_trips_every_variant() {
for ct in ContentType::ALL {
let json = serde_json::to_string(&ct).unwrap();
assert_eq!(json, format!("\"{ct}\""));
assert_eq!(serde_json::from_str::<ContentType>(&json).unwrap(), ct);
assert_eq!(ContentType::parse(ct.as_wire_str()), ct);
}
}
#[test]
fn content_type_round_trips_in_non_self_describing_formats() {
for ct in ContentType::ALL {
let bytes = postcard::to_allocvec(&ct).unwrap();
assert_eq!(postcard::from_bytes::<ContentType>(&bytes).unwrap(), ct);
}
}
#[test]
fn unknown_content_type_degrades_to_file_on_json_decode() {
assert_eq!(
serde_json::from_str::<ContentType>("\"some_future_type\"").unwrap(),
ContentType::File
);
assert!(serde_json::from_str::<ContentType>("7").is_err());
}
#[test]
fn message_with_unknown_content_type_still_decodes() {
let msg = create_test_message();
let mut value = serde_json::to_value(&msg).unwrap();
value["content_type"] = serde_json::Value::String("some_future_type".to_string());
let decoded: Message = serde_json::from_value(value).unwrap();
assert_eq!(decoded.content_type, ContentType::File);
assert_eq!(decoded.content, msg.content);
assert_eq!(decoded.id, msg.id);
}
}