diff --git a/src/client.rs b/src/client.rs index 69b59ef82..9083f5516 100644 --- a/src/client.rs +++ b/src/client.rs @@ -527,6 +527,19 @@ impl Client { self.shutdown_notifier.subscribe() } + /// Synchronous flag-only equivalent of the first lines of `disconnect()`. + /// Spawned tasks watching `is_shutting_down()` / `shutdown_notifier` exit + /// on their next poll. Does NOT flush, close the transport, or touch + /// persistence — prefer `disconnect()` whenever you can `await`. Exists + /// for `Drop` impls on FFI wrappers (e.g. `WasmWhatsAppClient`) that + /// can't run async cleanup synchronously. + pub fn signal_shutdown_sync(&self) { + self.expected_disconnect.store(true, Ordering::Relaxed); + self.is_running.store(false, Ordering::Relaxed); + self.shutdown_notifier.notify(); + self.notify_connection_shutdown(); + } + pub(crate) fn connection_shutdown_signal(&self) -> wacore::runtime::ShutdownSignal { self.connection_shutdown .lock() @@ -1829,12 +1842,26 @@ impl Client { } /// Determine if a node should be acknowledged with . + /// + /// Newsletter messages need `` (per + /// `OutMessageDeliverCommonAckMixin`); regular DM/group send a + /// `` instead. Status broadcasts also need the ack until + /// `send_delivery_receipt` stops skipping them. fn should_ack(&self, node: &wacore_binary::NodeRef<'_>) -> bool { - matches!( - node.tag.as_ref(), - "message" | "receipt" | "notification" | "call" - ) && node.get_attr("id").is_some() - && node.get_attr("from").is_some() + let tag = node.tag.as_ref(); + if node.get_attr("id").is_none() { + return false; + } + let Some(from) = node.get_attr("from") else { + return false; + }; + match tag { + "receipt" | "notification" | "call" => true, + "message" => from + .to_jid() + .is_some_and(|j| j.is_newsletter() || j.is_status_broadcast()), + _ => false, + } } /// Possibly send a deferred ack: either immediately or via spawned task. @@ -3833,7 +3860,13 @@ fn encode_ack_bytes( let Some(from_val) = node.get_attr("from") else { return Ok(None); }; - let participant_val = node.get_attr("participant"); + // WAWebReceiptAck: `participant: r && r !== e ? DEVICE_JID(r) : DROP_ATTR`. + // Drop the attribute when it would duplicate `to` (which is the flipped `from`). + let participant_val = node.get_attr("participant").filter(|p| { + let p_str = p.as_str(); + let from_str = from_val.as_str(); + p_str.as_ref() != from_str.as_ref() + }); let tag = node.tag.as_ref(); let typ_val = if tag != "message" && !is_encrypt_identity_notification(node) { @@ -3922,8 +3955,13 @@ fn encode_ack_bytes( #[cfg(test)] fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>) -> Option { let id = node.get_attr("id")?.to_node_value(); - let from = node.get_attr("from")?.to_node_value(); - let participant = node.get_attr("participant").map(|v| v.to_node_value()); + let from_ref = node.get_attr("from")?; + let from = from_ref.to_node_value(); + // Drop participant when it duplicates `to` (the flipped `from`). + let participant = node + .get_attr("participant") + .filter(|p| p.as_str().as_ref() != from_ref.as_str().as_ref()) + .map(|v| v.to_node_value()); let tag = node.tag.as_ref(); let typ = if tag != "message" && !is_encrypt_identity_notification(node) { node.get_attr("type").map(|v| v.to_node_value()) @@ -4047,6 +4085,53 @@ mod tests { "should_ack must still return TRUE for stanzas." ); + // Regular stanzas (DM / group) are acked via the delivery + // , not a bare . WA Web only emits + // for newsletter deliveries. + 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 (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 ." + ); + + 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 ." + ); + + // send_delivery_receipt skips status@broadcast, so the ack stays + // as the server-level acknowledgement until receipts cover it. + 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 until receipts cover it." + ); + info!( "✅ test_ack_behavior_for_incoming_stanzas passed: Client correctly differentiates which stanzas to acknowledge." ); @@ -5673,6 +5758,47 @@ mod tests { ); } + #[test] + fn test_build_ack_node_drops_participant_when_equal_to_from() { + // WAWebReceiptAck: `participant: r && r !== e ? DEVICE_JID(r) : DROP_ATTR`. + // When the incoming stanza carries participant == from (redundant), + // the ack must not echo it. + 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() { + // Group receipt: participant = sender (user), from = group jid; must be kept. + 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() { // Delivery receipts have no type attribute — the ack must also omit it. diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs index 1d5c73833..b5d439665 100644 --- a/wacore/src/iq/contacts.rs +++ b/wacore/src/iq/contacts.rs @@ -195,10 +195,9 @@ pub struct SetProfilePictureResponse { /// /// ## Wire Format (Remove) /// ```xml -/// -/// -/// +/// /// ``` +/// No `` child: `WAWebSendProfilePictureJob` emits an empty IQ. /// /// ## Response /// ```xml @@ -260,17 +259,18 @@ impl IqSpec for SetProfilePictureSpec { type Response = SetProfilePictureResponse; fn build_iq(&self) -> InfoQuery<'static> { - let mut picture_builder = NodeBuilder::new("picture").attr("type", "image"); - - if let Some(data) = &self.image_data { - picture_builder = picture_builder.bytes(data.clone()); - } - - let mut iq = InfoQuery::set( - "w:profile:picture", - Jid::new("", Server::Pn), - Some(NodeContent::Nodes(vec![picture_builder.build()])), - ); + // WAWebSendProfilePictureJob: emits `{bytes}` + // on set, NO `` child on remove. + let content = self.image_data.as_ref().map(|data| { + NodeContent::Nodes(vec![ + NodeBuilder::new("picture") + .attr("type", "image") + .bytes(data.clone()) + .build(), + ]) + }); + + let mut iq = InfoQuery::set("w:profile:picture", Jid::new("", Server::Pn), content); if let Some(target) = &self.target { iq = iq.with_target_ref(target); @@ -466,20 +466,24 @@ mod tests { } #[test] - fn test_set_profile_picture_spec_remove_own() { + fn test_set_profile_picture_spec_remove_own_emits_no_picture_child() { + // WAWebSendProfilePictureJob: removal IQ has no `` child at all. let spec = SetProfilePictureSpec::remove_own(); let iq = spec.build_iq(); + assert!( + iq.content.is_none(), + "Remove must emit an empty with no child; got {:?}", + iq.content + ); + } - if let Some(NodeContent::Nodes(nodes)) = &iq.content { - let picture = &nodes[0]; - // Remove: picture node with no content - assert!( - picture.content.is_none(), - "Remove should have no picture content" - ); - } else { - panic!("Expected NodeContent::Nodes"); - } + #[test] + fn test_set_profile_picture_spec_remove_group_emits_no_picture_child() { + let group_jid: Jid = "123456789@g.us".parse().unwrap(); + let spec = SetProfilePictureSpec::remove_group(&group_jid); + let iq = spec.build_iq(); + assert!(iq.content.is_none(), "Remove group: no child"); + assert_eq!(iq.target, Some(group_jid)); } #[test] diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 70bbe87b0..4b5ffb64e 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -267,6 +267,15 @@ pub struct GroupCreateOptions { /// Only used when `is_parent` is true. #[builder(default)] pub create_general_chat: bool, + /// Parent community to link this subgroup to. Atomic alternative to + /// creating then linking; mutually exclusive with `is_parent`. + #[builder(default, setter(strip_option, into))] + pub linked_parent: Option, + /// Inline description carried on the create stanza; avoids a follow-up + /// SetGroupDescription IQ. Validation (length cap) goes through + /// [`GroupDescription`] so both create paths share the same contract. + #[builder(default, setter(strip_option, into))] + pub description: Option, } impl GroupCreateOptions { @@ -322,11 +331,24 @@ impl Default for GroupCreateOptions { closed: false, allow_non_admin_sub_group_creation: false, create_general_chat: false, + linked_parent: None, + description: None, } } } /// Normalize participants: drop phone_number for non-LID JIDs. +/// Random 8-char hex token for a `` attribute. Shared +/// between create-with-inline-description and SetGroupDescriptionIq so the +/// RNG seeding stays in one place. +fn generate_description_id() -> String { + use rand::RngExt as _; + format!( + "{:08X}", + rand::make_rng::().random::() + ) +} + pub fn normalize_participants( participants: &[GroupParticipantOptions], ) -> Vec { @@ -406,8 +428,30 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { ); } - // Community (parent group) fields - if options.is_parent { + // `` (this group IS a community) and `` (this + // group is a subgroup of X) are mutually exclusive. When both are + // requested, `linked_parent` wins; it carries an explicit target. + debug_assert!( + options.linked_parent.is_none() || !options.is_parent, + "GroupCreateOptions: linked_parent and is_parent are mutually exclusive" + ); + if let Some(parent_jid) = &options.linked_parent { + if options.is_parent { + log::warn!( + "GroupCreateOptions has both linked_parent={parent_jid} and is_parent=true \ + (closed={}, allow_non_admin_sub_group_creation={}, create_general_chat={}); \ + dropping parent-only flags", + options.closed, + options.allow_non_admin_sub_group_creation, + options.create_general_chat, + ); + } + children.push( + NodeBuilder::new("linked_parent") + .attr("jid", parent_jid) + .build(), + ); + } else if options.is_parent { let mut parent_builder = NodeBuilder::new("parent"); if options.closed { parent_builder = @@ -423,6 +467,18 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { } } + // Inline description: WA Web emits `{text}`. + if let Some(desc) = &options.description { + children.push( + NodeBuilder::new("description") + .attr("id", generate_description_id()) + .children([NodeBuilder::new("body") + .string_content(desc.as_str()) + .build()]) + .build(), + ); + } + NodeBuilder::new("create") .attr("subject", &options.subject) .children(children) @@ -1047,14 +1103,13 @@ impl IqSpec for GroupCreateIq { let group_node = required_child(response, "group")?; let mut info = GroupInfoResponse::try_from_node_ref(group_node)?; - // The server may omit `` / `` - // from the create reply (WA Web's CreateJob never reads them either), - // so propagate the request flags so a freshly created community classifies - // correctly via `group_type()` without a follow-up metadata query. - // `allow_non_admin_sub_group_creation` is one-directional (request only - // fills when server omitted) so a server-set `true` is never clobbered - // by a `false` request flag. - if self.options.is_parent { + // Server may omit `` from a community-create reply; overlay + // request flags so `group_type()` classifies without a follow-up query. + // A `linked_parent` (in request or response) means this is a subgroup, + // so don't promote it to parent even if `is_parent` was requested. + let is_linked_subgroup = + info.parent_group_jid.is_some() || self.options.linked_parent.is_some(); + if self.options.is_parent && !is_linked_subgroup { info.is_parent_group = true; info.allow_non_admin_sub_group_creation |= self.options.allow_non_admin_sub_group_creation; @@ -1068,22 +1123,26 @@ impl IqSpec for GroupCreateIq { // Group Management IQ Specs // --------------------------------------------------------------------------- -/// Response for participant change operations. -/// -/// Success is signaled by absent `error`; `type` is often omitted by the server. -#[derive(Debug, Clone, crate::ProtocolNode)] -#[protocol(tag = "participant")] +/// V4 invite token returned in `` when privacy +/// blocks a direct add; lets callers fall back to a `GroupInviteMessage`. +/// Wire: ``. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddRequestInfo { + pub code: String, + pub expiration: u64, +} + +/// Response for participant change operations. Success: `error` is None; +/// `type` is often omitted by the server. On `error == "403"` the +/// `` child (`add_request` field) carries the V4 invite token. +#[derive(Debug, Clone)] pub struct ParticipantChangeResponse { - #[attr(name = "jid", jid)] pub jid: Jid, - #[attr(name = "type")] pub status: Option, - #[attr(name = "error")] pub error: Option, - #[attr(name = "phone_number", jid)] pub phone_number: Option, - #[attr(name = "username")] pub username: Option, + pub add_request: Option, } impl ParticipantChangeResponse { @@ -1092,6 +1151,87 @@ impl ParticipantChangeResponse { } } +impl crate::protocol::ProtocolNode for ParticipantChangeResponse { + fn tag(&self) -> &'static str { + "participant" + } + + fn into_node(self) -> ::wacore_binary::node::Node { + let mut builder = + ::wacore_binary::builder::NodeBuilder::new("participant").attr("jid", &self.jid); + if let Some(s) = self.status { + builder = builder.attr("type", s); + } + if let Some(e) = self.error { + builder = builder.attr("error", e); + } + if let Some(ref pn) = self.phone_number { + builder = builder.attr("phone_number", pn); + } + if let Some(u) = self.username { + builder = builder.attr("username", u); + } + if let Some(ar) = self.add_request { + builder = builder.children([::wacore_binary::builder::NodeBuilder::new("add_request") + .attr("code", ar.code) + .attr("expiration", ar.expiration) + .build()]); + } + builder.build() + } + + fn try_from_node_ref(node: &::wacore_binary::node::NodeRef<'_>) -> ::anyhow::Result { + if node.tag != "participant" { + return Err(::anyhow::anyhow!( + "expected , got <{}>", + node.tag + )); + } + let mut attrs = node.attrs(); + let jid = attrs + .optional_jid("jid") + .ok_or_else(|| ::anyhow::anyhow!("participant missing required 'jid' attribute"))?; + let status = attrs.optional_string("type").map(|c| c.into_owned()); + let error = attrs.optional_string("error").map(|c| c.into_owned()); + let phone_number = attrs.optional_jid("phone_number"); + let username = attrs.optional_string("username").map(|c| c.into_owned()); + + // Absent → None. Present but malformed → hard error so a server-side + // drop of the V4 invite token doesn't silently disappear. + let add_request = node + .get_optional_child("add_request") + .map(|n| -> ::anyhow::Result { + let mut a = n.attrs(); + let code = a + .optional_string("code") + .ok_or_else(|| { + ::anyhow::anyhow!(" missing required 'code' attribute") + })? + .into_owned(); + let expiration = a + .optional_string("expiration") + .ok_or_else(|| { + ::anyhow::anyhow!(" missing required 'expiration' attribute") + })? + .parse::() + .map_err(|e| { + ::anyhow::anyhow!(" 'expiration' is not a u64: {e}") + })?; + Ok(AddRequestInfo { code, expiration }) + }) + .transpose()?; + + Ok(Self { + jid, + status, + error, + phone_number, + username, + add_request, + }) + } +} + /// IQ specification for setting a group's subject. /// /// Wire format: @@ -1163,11 +1303,7 @@ impl SetGroupDescriptionIq { description: Option, prev: Option, ) -> Self { - use rand::RngExt; - let id = format!( - "{:08X}", - rand::make_rng::().random::() - ); + let id = generate_description_id(); Self { group_jid: group_jid.clone(), description, @@ -1616,6 +1752,9 @@ impl IqSpec for SetGroupAnnouncementIq { } } +/// Max for ``, per `WASmaxInGroupsGroupInfoMixin`. +pub const EPHEMERAL_TRIGGER_MAX: u32 = 20; + /// IQ specification for setting ephemeral (disappearing) messages on a group. /// /// Wire format: @@ -1628,6 +1767,7 @@ impl IqSpec for SetGroupAnnouncementIq { /// ``` /// /// Common expiration values (seconds): +/// /// - 86400 (24 hours) /// - 604800 (7 days) /// - 7776000 (90 days) @@ -1637,6 +1777,9 @@ pub struct SetGroupEphemeralIq { pub group_jid: Jid, /// Expiration in seconds. `None` means disable. pub expiration: Option, + /// `trigger` attr on `` (0..=[`EPHEMERAL_TRIGGER_MAX`]); + /// identifies the disappearing-mode source. `None` omits the attr. + pub trigger: Option, } impl SetGroupEphemeralIq { @@ -1645,6 +1788,23 @@ impl SetGroupEphemeralIq { Self { group_jid: group_jid.clone(), expiration: Some(expiration), + trigger: None, + } + } + + /// Enable ephemeral messages with an explicit `trigger`. + /// + /// # Panics + /// If `trigger > EPHEMERAL_TRIGGER_MAX`. + pub fn enable_with_trigger(group_jid: &Jid, expiration: NonZeroU32, trigger: u32) -> Self { + assert!( + trigger <= EPHEMERAL_TRIGGER_MAX, + "ephemeral trigger must be in 0..={EPHEMERAL_TRIGGER_MAX}, got {trigger}" + ); + Self { + group_jid: group_jid.clone(), + expiration: Some(expiration), + trigger: Some(trigger), } } @@ -1653,6 +1813,7 @@ impl SetGroupEphemeralIq { Self { group_jid: group_jid.clone(), expiration: None, + trigger: None, } } } @@ -1662,9 +1823,18 @@ impl IqSpec for SetGroupEphemeralIq { fn build_iq(&self) -> InfoQuery<'static> { let node = match self.expiration { - Some(exp) => NodeBuilder::new("ephemeral") - .attr("expiration", exp.get()) - .build(), + Some(exp) => { + let mut b = NodeBuilder::new("ephemeral").attr("expiration", exp.get()); + // Skip out-of-range triggers instead of emitting them; the + // constructor asserts on misuse, this is the defence-in-depth + // path for direct field assignment. + if let Some(trigger) = self.trigger + && trigger <= EPHEMERAL_TRIGGER_MAX + { + b = b.attr("trigger", trigger); + } + b.build() + } None => NodeBuilder::new("not_ephemeral").build(), }; InfoQuery::set_ref( @@ -3143,6 +3313,73 @@ mod tests { assert_eq!(result.username.as_deref(), Some("example_user")); } + #[test] + fn test_participant_change_response_parses_add_request_on_403() { + // WAWebInGroupsParticipantRequestCodeCanBeSentMixin: on error="403" + // the server returns the V4 invite token in . + let node = NodeBuilder::new("participant") + .attr("jid", "5511999999999@s.whatsapp.net") + .attr("error", "403") + .children([NodeBuilder::new("add_request") + .attr("code", "ABC123DEF") + .attr("expiration", "1735689600") + .build()]) + .build(); + + let result = ParticipantChangeResponse::try_from_node(&node).unwrap(); + assert_eq!(result.error.as_deref(), Some("403")); + let ar = result + .add_request + .expect("403 response must carry the add_request token"); + assert_eq!(ar.code, "ABC123DEF"); + assert_eq!(ar.expiration, 1735689600); + } + + #[test] + fn test_participant_change_response_no_add_request_on_success() { + let node = NodeBuilder::new("participant") + .attr("jid", "5511999999999@s.whatsapp.net") + .build(); + let result = ParticipantChangeResponse::try_from_node(&node).unwrap(); + assert!(result.add_request.is_none()); + } + + #[test] + fn test_participant_change_response_rejects_missing_jid() { + let node = NodeBuilder::new("participant").attr("error", "403").build(); + let err = ParticipantChangeResponse::try_from_node(&node) + .expect_err("missing jid must be a hard error"); + assert!(err.to_string().contains("missing required 'jid' attribute")); + } + + #[test] + fn test_participant_change_response_rejects_malformed_add_request() { + // present but no code → hard error. + let node = NodeBuilder::new("participant") + .attr("jid", "5511999999999@s.whatsapp.net") + .attr("error", "403") + .children([NodeBuilder::new("add_request") + .attr("expiration", "1735689600") + .build()]) + .build(); + let err = ParticipantChangeResponse::try_from_node(&node) + .expect_err("missing add_request code must be a hard error"); + assert!(err.to_string().contains("missing required 'code'")); + + // with non-numeric expiration → hard error. + let node = NodeBuilder::new("participant") + .attr("jid", "5511999999999@s.whatsapp.net") + .attr("error", "403") + .children([NodeBuilder::new("add_request") + .attr("code", "ABC") + .attr("expiration", "not-a-number") + .build()]) + .build(); + let err = ParticipantChangeResponse::try_from_node(&node) + .expect_err("non-u64 expiration must be a hard error"); + assert!(err.to_string().contains("'expiration' is not a u64")); + } + #[test] fn test_set_group_locked_iq() { let group: Jid = "120363000000000001@g.us".parse().unwrap(); @@ -3212,6 +3449,75 @@ mod tests { } } + #[test] + fn test_set_group_ephemeral_iq_with_trigger() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + let with_trigger = + SetGroupEphemeralIq::enable_with_trigger(&group, NonZeroU32::new(604800).unwrap(), 7); + let iq = with_trigger.build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected nodes content"); + }; + let mut attrs = nodes[0].attrs(); + assert_eq!( + attrs.optional_string("expiration").as_deref(), + Some("604800") + ); + assert_eq!(attrs.optional_string("trigger").as_deref(), Some("7")); + } + + #[test] + fn test_set_group_ephemeral_iq_accepts_trigger_at_max() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + // Boundary: trigger == EPHEMERAL_TRIGGER_MAX must succeed. + let _iq = SetGroupEphemeralIq::enable_with_trigger( + &group, + NonZeroU32::new(86400).unwrap(), + EPHEMERAL_TRIGGER_MAX, + ); + } + + #[test] + #[should_panic(expected = "ephemeral trigger must be in 0..=20")] + fn test_set_group_ephemeral_iq_rejects_trigger_above_max() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + let _ = SetGroupEphemeralIq::enable_with_trigger( + &group, + NonZeroU32::new(86400).unwrap(), + EPHEMERAL_TRIGGER_MAX + 1, + ); + } + + #[test] + fn test_set_group_ephemeral_iq_without_trigger_omits_attr() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + let iq = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap()).build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected nodes content"); + }; + assert!( + nodes[0].attrs().optional_string("trigger").is_none(), + "default enable() must not emit a trigger attribute" + ); + } + + #[test] + fn test_set_group_ephemeral_iq_skips_out_of_range_trigger_in_build_iq() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + // Bypass the constructor and write directly to mimic a caller that + // sets the public field to an invalid value. + let mut iq_spec = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap()); + iq_spec.trigger = Some(EPHEMERAL_TRIGGER_MAX + 1); + let iq = iq_spec.build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected nodes content"); + }; + assert!( + nodes[0].attrs().optional_string("trigger").is_none(), + "out-of-range trigger must be dropped on the wire" + ); + } + #[test] fn test_set_group_membership_approval_iq() { let group: Jid = "120363000000000001@g.us".parse().unwrap(); @@ -3590,6 +3896,132 @@ mod tests { assert!(response.allow_non_admin_sub_group_creation); } + #[test] + fn test_group_create_iq_emits_linked_parent() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let options = GroupCreateOptions { + subject: "Subgroup".into(), + linked_parent: Some(parent.clone()), + ..Default::default() + }; + let iq = GroupCreateIq::new(options).build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected "); + }; + let linked = nodes[0] + .get_optional_child("linked_parent") + .expect("linked_parent child must be emitted"); + assert_eq!( + linked.attrs().jid("jid"), + parent, + "linked_parent jid must match the requested parent" + ); + } + + #[test] + #[cfg_attr(debug_assertions, should_panic(expected = "mutually exclusive"))] + fn test_group_create_iq_linked_parent_excludes_parent_block() { + // Setting both is a programmer error: debug builds panic on the + // debug_assert; release builds silently emit only . + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let options = GroupCreateOptions { + subject: "Conflicting".into(), + is_parent: true, + closed: true, + allow_non_admin_sub_group_creation: true, + create_general_chat: true, + linked_parent: Some(parent.clone()), + ..Default::default() + }; + let iq = GroupCreateIq::new(options).build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected "); + }; + assert!(nodes[0].get_optional_child("linked_parent").is_some()); + assert!(nodes[0].get_optional_child("parent").is_none()); + assert!( + nodes[0] + .get_optional_child("allow_non_admin_sub_group_creation") + .is_none() + ); + assert!(nodes[0].get_optional_child("create_general_chat").is_none()); + } + + #[test] + fn test_group_create_iq_parse_response_does_not_promote_subgroup_to_parent() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let options = GroupCreateOptions { + subject: "Subgroup".into(), + is_parent: true, + allow_non_admin_sub_group_creation: true, + linked_parent: Some(parent.clone()), + ..Default::default() + }; + let spec = GroupCreateIq::new(options); + + let iq = NodeBuilder::new("iq") + .children([NodeBuilder::new("group") + .attr("id", "120363999999999999") + .attr("subject", "Subgroup") + .children([NodeBuilder::new("linked_parent") + .attr("jid", &parent) + .build()]) + .build()]) + .build(); + let response = spec.parse_response(&iq.as_node_ref()).unwrap(); + + assert!(!response.is_parent_group); + assert_eq!(response.parent_group_jid, Some(parent)); + assert!(!response.allow_non_admin_sub_group_creation); + } + + #[test] + fn test_group_create_iq_emits_description_with_body() { + let options = GroupCreateOptions { + subject: "Group with desc".into(), + description: Some(GroupDescription::new("Hello, group").unwrap()), + ..Default::default() + }; + let iq = GroupCreateIq::new(options).build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected "); + }; + let desc = nodes[0] + .get_optional_child("description") + .expect("description child must be emitted"); + assert!( + desc.attrs() + .optional_string("id") + .is_some_and(|id| !id.is_empty()), + "description must carry an opaque id token" + ); + let body = desc + .get_optional_child("body") + .expect("description must have a body child"); + let text = match &body.content { + Some(NodeContent::String(s)) => s.to_string(), + Some(NodeContent::Bytes(b)) => String::from_utf8_lossy(b).into_owned(), + _ => panic!("description body must carry text"), + }; + assert_eq!(text, "Hello, group"); + } + + #[test] + fn test_group_create_iq_description_rejects_over_max_length() { + let too_long = "x".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1); + assert!(GroupDescription::new(too_long).is_err()); + } + + #[test] + fn test_group_create_iq_omits_linked_parent_and_description_by_default() { + let iq = GroupCreateIq::new(GroupCreateOptions::new("Plain")).build_iq(); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected "); + }; + assert!(nodes[0].get_optional_child("linked_parent").is_none()); + assert!(nodes[0].get_optional_child("description").is_none()); + } + /// Plain (non-community) group create: overlay branch must not run, both /// flags stay at the parsed defaults (`false`). #[test] diff --git a/wacore/src/proto_helpers.rs b/wacore/src/proto_helpers.rs index a483ef0eb..548a3b0e7 100644 --- a/wacore/src/proto_helpers.rs +++ b/wacore/src/proto_helpers.rs @@ -2,12 +2,10 @@ use std::str::FromStr; use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; -/// Invokes a callback macro with the list of all message types that have `context_info`. -/// -/// This macro ensures both `for_each_context_info_message!` and `set_context_info_on_message!` -/// use the same list of message types, making it easy to add new types in one place. -/// -/// When WhatsApp adds new message types with context_info, add them here. +/// Single source of truth for the message types that carry a `context_info` +/// field. Consumed by `for_each_context_info_message!`, `set_context_info` +/// (via an inlined `try_attach!`), `get_ephemeral_expiration`, and +/// `set_ephemeral_expiration`. Add new WA message types with context_info here. macro_rules! with_context_info_fields { ($callback:ident!($($prefix:tt)*)) => { $callback!($($prefix)* @@ -66,27 +64,6 @@ macro_rules! for_each_context_info_impl { }; } -/// Sets context_info on the first matching message type. -/// Returns true if context was set, false otherwise. -macro_rules! set_context_info_on_message { - ($msg:expr, $ctx:expr) => { - with_context_info_fields!(set_context_info_impl!($msg, $ctx,)) - }; -} - -macro_rules! set_context_info_impl { - ($msg:expr, $ctx:expr, $($field:ident),+ $(,)?) => {{ - let ctx = $ctx; - $( - if let Some(ref mut m) = $msg.$field { - m.context_info = Some(ctx); - return true; - } - )+ - false - }}; -} - /// Extension trait for wa::Message pub trait MessageExt { /// Recursively unwraps ephemeral/view-once/document_with_caption/edited wrappers to get the core message. @@ -153,9 +130,11 @@ pub trait MessageExt { /// Reads `context_info.expiration` from the first message type that has it. fn get_ephemeral_expiration(&self) -> Option; - /// Sets `context_info.expiration` on the first message type found. - /// Creates a default `context_info` if needed. Returns `false` for - /// bare `conversation` messages (use `ExtendedTextMessage` instead). + /// Sets `context_info.expiration` on the first message type found, creating + /// a default `context_info` if needed. A bare `conversation` body is + /// promoted to `extended_text_message { text, context_info { expiration } }` + /// (mirrors `WAWebMessageSendUtils`). Returns `true` on success or + /// promotion, `false` only when no body can carry the timer. fn set_ephemeral_expiration(&mut self, expiration: u32) -> bool; } @@ -315,7 +294,29 @@ impl MessageExt for wa::Message { } fn set_context_info(&mut self, context: wa::ContextInfo) -> bool { - set_context_info_on_message!(self, Box::new(context)) + macro_rules! try_attach { + ($($field:ident),+ $(,)?) => { + $( + if let Some(ref mut m) = self.$field { + m.context_info = Some(Box::new(context)); + return true; + } + )+ + }; + } + with_context_info_fields!(try_attach!()); + + // Promote bare conversation to extended_text_message so the context + // can attach; matches WAWebMessageSendUtils. + if let Some(text) = self.conversation.take() { + self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage { + text: Some(text), + context_info: Some(Box::new(context)), + ..Default::default() + })); + return true; + } + false } fn get_ephemeral_expiration(&self) -> Option { @@ -354,6 +355,21 @@ impl MessageExt for wa::Message { }; } with_context_info_fields!(try_set!()); + + // Promote bare conversation so the timer can attach; matches + // WAWebMessageSendUtils. + if let Some(text) = self.conversation.take() { + self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage { + text: Some(text), + context_info: Some(Box::new(wa::ContextInfo { + expiration: Some(expiration), + ..Default::default() + })), + ..Default::default() + })); + return true; + } + false } } @@ -897,9 +913,8 @@ mod tests { assert!(loc.context_info.is_some()); } - /// Test: set_context_info returns false for unsupported message types #[test] - fn test_set_context_info_unsupported() { + fn test_set_context_info_promotes_bare_conversation() { let mut msg = wa::Message { conversation: Some("Simple text".to_string()), ..Default::default() @@ -910,7 +925,30 @@ mod tests { ..Default::default() }; + assert!(msg.set_context_info(context)); + assert!(msg.conversation.is_none(), "conversation must be moved out"); + let ext = msg + .extended_text_message + .expect("promoted to extended_text_message"); + assert_eq!(ext.text.as_deref(), Some("Simple text")); + assert_eq!( + ext.context_info + .as_ref() + .and_then(|c| c.stanza_id.as_deref()), + Some("test-id") + ); + } + + #[test] + fn test_set_context_info_returns_false_on_empty_message() { + let mut msg = wa::Message::default(); + let context = wa::ContextInfo { + stanza_id: Some("test-id".to_string()), + ..Default::default() + }; assert!(!msg.set_context_info(context)); + assert!(msg.conversation.is_none()); + assert!(msg.extended_text_message.is_none()); } /// Test: build_quote_context produces correct structure. @@ -1826,6 +1864,30 @@ mod tests { assert!(msg.is_view_once()); } + #[test] + fn set_ephemeral_expiration_promotes_bare_conversation_to_extended_text() { + let mut msg = wa::Message { + conversation: Some("hello".to_string()), + ..Default::default() + }; + assert!(msg.set_ephemeral_expiration(86400)); + assert!(msg.conversation.is_none()); + let ext = msg.extended_text_message.unwrap(); + assert_eq!(ext.text.as_deref(), Some("hello")); + assert_eq!( + ext.context_info.as_ref().and_then(|c| c.expiration), + Some(86400) + ); + } + + #[test] + fn set_ephemeral_expiration_returns_false_on_empty_message() { + let mut msg = wa::Message::default(); + assert!(!msg.set_ephemeral_expiration(60)); + assert!(msg.conversation.is_none()); + assert!(msg.extended_text_message.is_none()); + } + #[test] fn is_view_once_detects_ephemeral_device_sent_view_once() { let msg = wa::Message { diff --git a/wacore/src/stanza/groups.rs b/wacore/src/stanza/groups.rs index bf582a8c9..d7ddd6b07 100644 --- a/wacore/src/stanza/groups.rs +++ b/wacore/src/stanza/groups.rs @@ -211,6 +211,76 @@ pub enum GroupNotificationAction { #[wire(skip)] raw: Node, }, + /// `` — Subgroup admin elevated (community parent). + #[wire = "linked_group_promote"] + LinkedGroupPromote { + participants: Vec, + }, + /// `` — Subgroup admin demoted. + #[wire = "linked_group_demote"] + LinkedGroupDemote { + participants: Vec, + }, + + // -- State toggles -- + /// `` — Group suspended by Meta moderation. + #[wire = "suspended"] + Suspended, + /// `` — Suspension lifted. + #[wire = "unsuspended"] + Unsuspended, + /// `` — Community auto-add to general chat disabled. + #[wire = "auto_add_disabled"] + AutoAddDisabled, + /// `` — Group routed through CAPI hosted server. + #[wire = "is_capi_hosted_group"] + IsCapiHostedGroup, + /// `` — Integrity check toggle. + #[wire = "group_safety_check"] + GroupSafetyCheck, + /// `` — Limit sharing toggle. The + /// optional `trigger` attribute (range 0..20 per + /// `WASmaxInGroupsGroupInfoMixin`) identifies the source of the change. + #[wire = "limit_sharing_enabled"] + LimitSharingEnabled { trigger: Option }, + /// `` — Admins may receive report alerts. + #[wire = "allow_admin_reports"] + AllowAdminReports, + /// `` — Admin reports disabled. + #[wire = "not_allow_admin_reports"] + NotAllowAdminReports, + /// `` — Generic reports notification. + #[wire = "reports"] + Reports, + /// `` — Community permits members to + /// create subgroups without admin approval. + #[wire = "allow_non_admin_sub_group_creation"] + AllowNonAdminSubGroupCreation, + /// `` — Community restricts + /// subgroup creation to admins. + #[wire = "not_allow_non_admin_sub_group_creation"] + NotAllowNonAdminSubGroupCreation, + + // -- Subgroup suggestions (community) -- + /// `` — Suggested subgroup added. + #[wire = "created_sub_group_suggestion"] + CreatedSubGroupSuggestion { + #[wire(skip)] + raw: Node, + }, + /// `` — Subgroup suggestion revoked. + #[wire = "revoked_sub_group_suggestions"] + RevokedSubGroupSuggestions { + #[wire(skip)] + raw: Node, + }, + + /// `` — Participant changed their phone number; new + /// participant data is in the `` child. + #[wire = "change_number"] + ChangeNumber { + participants: Vec, + }, // -- Catch-all -- /// Unknown child tag — preserved for forward compatibility. The `tag` @@ -220,10 +290,9 @@ pub enum GroupNotificationAction { } impl GroupNotification { - /// Parse from a `NodeRef`. - /// - /// Most fields are parsed zero-copy. Only `Create`/`Link`/`Unlink` actions - /// call `.to_owned()` on their specific child node (structurally required to store `raw: Node`). + /// Parse from a `NodeRef`. Most fields are zero-copy; `Create`, `Link`, + /// `Unlink`, `CreatedSubGroupSuggestion` and `RevokedSubGroupSuggestions` + /// call `.to_owned()` to store their child as `raw: Node`. pub fn try_from_node_ref(node: &NodeRef<'_>) -> Option { let mut attrs = node.attrs(); let group_jid = attrs.optional_jid("from")?; @@ -258,7 +327,9 @@ impl GroupNotification { /// function. If the `#[wire = "..."]` attribute on a variant changes, both /// the serializer and this dispatcher track it automatically. /// -/// Only `Create`/`Link`/`Unlink` call `.to_owned()` because those variants store `raw: Node`. +/// `Create`, `Link`, `Unlink`, `CreatedSubGroupSuggestion` and +/// `RevokedSubGroupSuggestions` call `.to_owned()` because those variants +/// store `raw: Node`. fn parse_action(node: &NodeRef<'_>) -> Option { use GroupNotificationActionTag as T; use wacore_binary::NodeContentRef; @@ -334,8 +405,15 @@ fn parse_action(node: &NodeRef<'_>) -> Option { // below produce `Ephemeral { expiration: 0, trigger: None }`, matching // WA Web's collapse of the two wire tags into one action. T::Ephemeral => GroupNotificationAction::Ephemeral { - expiration: node.attrs().optional_u64("expiration").unwrap_or(0) as u32, - trigger: node.attrs().optional_u64("trigger").map(|t| t as u32), + expiration: node + .attrs() + .optional_u64("expiration") + .and_then(|t| t.try_into().ok()) + .unwrap_or(0), + trigger: node + .attrs() + .optional_u64("trigger") + .and_then(|t| t.try_into().ok()), }, T::MembershipApprovalMode => { let enabled = node @@ -386,7 +464,11 @@ fn parse_action(node: &NodeRef<'_>) -> Option { }, T::RevokeInvite => GroupNotificationAction::RevokeInvite, T::GrowthLocked => GroupNotificationAction::GrowthLocked { - expiration: node.attrs().optional_u64("expiration").unwrap_or(0) as u32, + expiration: node + .attrs() + .optional_u64("expiration") + .and_then(|t| t.try_into().ok()) + .unwrap_or(0), lock_type: node .attrs() .optional_string("type") @@ -426,6 +508,40 @@ fn parse_action(node: &NodeRef<'_>) -> Option { .map(|s| s.into_owned()), raw: node.to_owned(), }, + T::LinkedGroupPromote => GroupNotificationAction::LinkedGroupPromote { + participants: parse_participants(node), + }, + T::LinkedGroupDemote => GroupNotificationAction::LinkedGroupDemote { + participants: parse_participants(node), + }, + T::Suspended => GroupNotificationAction::Suspended, + T::Unsuspended => GroupNotificationAction::Unsuspended, + T::AutoAddDisabled => GroupNotificationAction::AutoAddDisabled, + T::IsCapiHostedGroup => GroupNotificationAction::IsCapiHostedGroup, + T::GroupSafetyCheck => GroupNotificationAction::GroupSafetyCheck, + T::LimitSharingEnabled => GroupNotificationAction::LimitSharingEnabled { + // try_into so >u32::MAX yields None instead of silently truncating. + trigger: node + .attrs() + .optional_u64("trigger") + .and_then(|t| t.try_into().ok()), + }, + T::AllowAdminReports => GroupNotificationAction::AllowAdminReports, + T::NotAllowAdminReports => GroupNotificationAction::NotAllowAdminReports, + T::Reports => GroupNotificationAction::Reports, + T::AllowNonAdminSubGroupCreation => GroupNotificationAction::AllowNonAdminSubGroupCreation, + T::NotAllowNonAdminSubGroupCreation => { + GroupNotificationAction::NotAllowNonAdminSubGroupCreation + } + T::CreatedSubGroupSuggestion => GroupNotificationAction::CreatedSubGroupSuggestion { + raw: node.to_owned(), + }, + T::RevokedSubGroupSuggestions => GroupNotificationAction::RevokedSubGroupSuggestions { + raw: node.to_owned(), + }, + T::ChangeNumber => GroupNotificationAction::ChangeNumber { + participants: parse_participants(node), + }, T::Unknown(_) => GroupNotificationAction::Unknown { tag: node.tag.to_string(), }, @@ -840,6 +956,169 @@ mod tests { } } + #[test] + fn test_parse_linked_group_promote_demote_carry_participants() { + let promote_node = make_notification(vec![ + NodeBuilder::new("linked_group_promote") + .children(vec![ + NodeBuilder::new("participant") + .attr("jid", user_jid()) + .build(), + ]) + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&promote_node.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::LinkedGroupPromote { participants } => { + assert_eq!(participants.len(), 1); + assert_eq!(participants[0].jid, user_jid()); + } + other => panic!("expected LinkedGroupPromote, got {:?}", other), + } + + let demote_node = make_notification(vec![ + NodeBuilder::new("linked_group_demote") + .children(vec![ + NodeBuilder::new("participant") + .attr("jid", user_jid()) + .build(), + ]) + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&demote_node.as_node_ref()).unwrap(); + assert!(matches!( + notif.actions[0], + GroupNotificationAction::LinkedGroupDemote { .. } + )); + } + + #[test] + fn test_parse_suspended_toggle_variants() { + type Matcher = fn(&GroupNotificationAction) -> bool; + let table: &[(&str, Matcher)] = &[ + ("suspended", |a| { + matches!(a, GroupNotificationAction::Suspended) + }), + ("unsuspended", |a| { + matches!(a, GroupNotificationAction::Unsuspended) + }), + ("auto_add_disabled", |a| { + matches!(a, GroupNotificationAction::AutoAddDisabled) + }), + ("is_capi_hosted_group", |a| { + matches!(a, GroupNotificationAction::IsCapiHostedGroup) + }), + ("group_safety_check", |a| { + matches!(a, GroupNotificationAction::GroupSafetyCheck) + }), + ("limit_sharing_enabled", |a| { + matches!(a, GroupNotificationAction::LimitSharingEnabled { .. }) + }), + ("allow_admin_reports", |a| { + matches!(a, GroupNotificationAction::AllowAdminReports) + }), + ("not_allow_admin_reports", |a| { + matches!(a, GroupNotificationAction::NotAllowAdminReports) + }), + ("reports", |a| matches!(a, GroupNotificationAction::Reports)), + ("allow_non_admin_sub_group_creation", |a| { + matches!(a, GroupNotificationAction::AllowNonAdminSubGroupCreation) + }), + ("not_allow_non_admin_sub_group_creation", |a| { + matches!(a, GroupNotificationAction::NotAllowNonAdminSubGroupCreation) + }), + ]; + for (tag, matcher) in table { + let node = make_notification(vec![NodeBuilder::new(tag).build()]); + let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); + assert!( + matcher(¬if.actions[0]), + "wire tag {tag} parsed to unexpected variant: {:?}", + notif.actions[0] + ); + } + } + + #[test] + fn test_parse_limit_sharing_enabled_captures_trigger() { + let with_trigger = make_notification(vec![ + NodeBuilder::new("limit_sharing_enabled") + .attr("trigger", "5") + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&with_trigger.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::LimitSharingEnabled { trigger } => { + assert_eq!(*trigger, Some(5)); + } + other => panic!("expected LimitSharingEnabled, got {:?}", other), + } + + let no_trigger = make_notification(vec![NodeBuilder::new("limit_sharing_enabled").build()]); + let notif = GroupNotification::try_from_node_ref(&no_trigger.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::LimitSharingEnabled { trigger } => assert!(trigger.is_none()), + other => panic!("expected LimitSharingEnabled, got {:?}", other), + } + } + + #[test] + fn test_parse_limit_sharing_enabled_overflow_yields_none() { + // trigger > u32::MAX must yield None instead of truncating. + let node = make_notification(vec![ + NodeBuilder::new("limit_sharing_enabled") + .attr("trigger", "4294967296") + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::LimitSharingEnabled { trigger } => assert!(trigger.is_none()), + other => panic!("expected LimitSharingEnabled, got {:?}", other), + } + } + + #[test] + fn test_parse_ephemeral_overflow_yields_zero_and_none() { + // expiration > u32::MAX falls back to 0; trigger > u32::MAX yields None. + let node = make_notification(vec![ + NodeBuilder::new("ephemeral") + .attr("expiration", "4294967296") + .attr("trigger", "4294967296") + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::Ephemeral { + expiration, + trigger, + } => { + assert_eq!(*expiration, 0); + assert!(trigger.is_none()); + } + other => panic!("expected Ephemeral, got {:?}", other), + } + } + + #[test] + fn test_parse_change_number_carries_participants() { + let node = make_notification(vec![ + NodeBuilder::new("change_number") + .children(vec![ + NodeBuilder::new("participant") + .attr("jid", user_jid()) + .build(), + ]) + .build(), + ]); + let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); + match ¬if.actions[0] { + GroupNotificationAction::ChangeNumber { participants } => { + assert_eq!(participants.len(), 1); + } + other => panic!("expected ChangeNumber, got {:?}", other), + } + } + #[test] fn test_parse_unknown_tag() { let node = make_notification(vec![NodeBuilder::new("some_future_feature").build()]); @@ -939,12 +1218,39 @@ mod tests { GroupNotificationAction::Unlink { unlink_type: "x".into(), unlink_reason: None, - raw: dummy_node, + raw: dummy_node.clone(), + }, + GroupNotificationAction::LinkedGroupPromote { + participants: vec![], + }, + GroupNotificationAction::LinkedGroupDemote { + participants: vec![], + }, + GroupNotificationAction::Suspended, + GroupNotificationAction::Unsuspended, + GroupNotificationAction::AutoAddDisabled, + GroupNotificationAction::IsCapiHostedGroup, + GroupNotificationAction::GroupSafetyCheck, + GroupNotificationAction::LimitSharingEnabled { trigger: None }, + GroupNotificationAction::AllowAdminReports, + GroupNotificationAction::NotAllowAdminReports, + GroupNotificationAction::Reports, + GroupNotificationAction::AllowNonAdminSubGroupCreation, + GroupNotificationAction::NotAllowNonAdminSubGroupCreation, + GroupNotificationAction::CreatedSubGroupSuggestion { + raw: dummy_node.clone(), + }, + GroupNotificationAction::RevokedSubGroupSuggestions { + raw: dummy_node.clone(), + }, + GroupNotificationAction::ChangeNumber { + participants: vec![], }, GroupNotificationAction::Unknown { tag: "future_tag".into(), }, ]; + let _ = dummy_node; for action in &samples { let value = serde_json::to_value(action).expect("serialize");