Skip to main content

whatsapp_rust/features/
polls.rs

1//! Poll creation, voting, and vote decryption.
2
3use std::collections::HashMap;
4
5use thiserror::Error;
6use wacore::poll;
7use wacore_binary::{Jid, JidExt};
8use waproto::whatsapp as wa;
9
10use crate::client::Client;
11use crate::send::{SendError, SendResult};
12
13pub use wacore::poll::PollVoteCiphertext;
14
15/// Errors from poll operations (creation, voting, and vote decryption).
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum PollError {
19    /// Sending the poll/vote stanza failed (embeds the send path error).
20    #[error("{0}")]
21    Send(#[from] SendError),
22    /// The poll definition is invalid (option count, duplicate names, bad
23    /// quiz index, selectable count out of range).
24    #[error("invalid poll: {0}")]
25    InvalidPoll(String),
26    /// The client is not logged in, so the voter identity can't be resolved.
27    #[error("client is not logged in")]
28    NotLoggedIn,
29    /// Vote decryption/encryption (HKDF/GCM) failed.
30    #[error("poll vote crypto failed: {0}")]
31    Crypto(#[source] anyhow::Error),
32}
33
34#[derive(Debug, Clone)]
35pub struct PollOptionResult {
36    pub name: String,
37    pub voters: Vec<String>,
38}
39
40pub struct Polls<'a> {
41    client: &'a Client,
42}
43
44impl<'a> Polls<'a> {
45    pub(crate) fn new(client: &'a Client) -> Self {
46        Self { client }
47    }
48
49    /// Caller needs the returned `message_secret` to decrypt votes.
50    pub async fn create(
51        &self,
52        to: impl Into<Jid>,
53        name: &str,
54        options: &[String],
55        selectable_count: u32,
56    ) -> Result<(SendResult, Vec<u8>), PollError> {
57        let to = &to.into();
58        self.create_inner(to, name, options, selectable_count, None)
59            .await
60    }
61
62    /// Create a quiz poll: a single-select poll with exactly one correct option.
63    ///
64    /// `correct_index` is the 0-based index into `options` of the right answer.
65    /// Quizzes are inherently single-select (WA Web forces `selectableOptionsCount=1`),
66    /// so the count is fixed at 1. Returns the `message_secret` needed to decrypt votes.
67    pub async fn create_quiz(
68        &self,
69        to: impl Into<Jid>,
70        name: &str,
71        options: &[String],
72        correct_index: usize,
73    ) -> Result<(SendResult, Vec<u8>), PollError> {
74        let to = &to.into();
75        self.create_inner(to, name, options, 1, Some(correct_index))
76            .await
77    }
78
79    async fn create_inner(
80        &self,
81        to: &Jid,
82        name: &str,
83        options: &[String],
84        selectable_count: u32,
85        correct_index: Option<usize>,
86    ) -> Result<(SendResult, Vec<u8>), PollError> {
87        let poll_msg = build_poll_creation_message(name, options, selectable_count, correct_index)?;
88
89        // WA Web: v3 for single-select, v1 for multi-select (GeneratePollCreationMessageProto.js:39-41)
90        let mut message = if selectable_count == 1 {
91            wa::Message {
92                poll_creation_message_v3: buffa::MessageField::some(poll_msg),
93                ..Default::default()
94            }
95        } else {
96            wa::Message {
97                poll_creation_message: buffa::MessageField::some(poll_msg),
98                ..Default::default()
99            }
100        };
101
102        // WA Web generates a 32-byte random secret at poll creation time
103        // (SendPollCreationMsgAction.js:158). Voters need this to derive their encryption key.
104        let message_secret: Vec<u8> = {
105            use rand::Rng;
106            let mut secret = vec![0u8; 32];
107            rand::rng().fill_bytes(&mut secret);
108            secret
109        };
110
111        message.message_context_info = buffa::MessageField::some(wa::MessageContextInfo {
112            message_secret: Some(message_secret.clone()),
113            ..Default::default()
114        });
115
116        let result = self.client.send_message(to, message).await?;
117        Ok((result, message_secret))
118    }
119
120    pub async fn vote(
121        &self,
122        chat_jid: impl Into<Jid>,
123        poll_msg_id: &str,
124        poll_creator_jid: &Jid,
125        message_secret: &[u8],
126        option_names: &[String],
127    ) -> Result<SendResult, PollError> {
128        let chat_jid = &chat_jid.into();
129        let my_jid = self.client.pn().ok_or(PollError::NotLoggedIn)?;
130        let my_base = my_jid.to_non_ad();
131
132        let voter_jid = self
133            .resolve_voter_jid(poll_creator_jid, &my_base, poll_msg_id)
134            .await;
135        let voter_jid_str = voter_jid.to_string();
136        let creator_jid_str = poll_creator_jid.to_non_ad_string();
137
138        let selected_hashes: Vec<Vec<u8>> = option_names
139            .iter()
140            .map(|name| poll::compute_option_hash(name).to_vec())
141            .collect();
142
143        let (enc_payload, iv) = poll::encrypt_poll_vote_with_secret(
144            &selected_hashes,
145            message_secret,
146            poll_msg_id,
147            &creator_jid_str,
148            &voter_jid_str,
149        )
150        .map_err(PollError::Crypto)?;
151
152        let from_me = my_base.is_same_user_as(poll_creator_jid);
153
154        let poll_update = wa::message::PollUpdateMessage {
155            poll_creation_message_key: buffa::MessageField::some(wa::MessageKey {
156                remote_jid: Some(chat_jid.to_string()),
157                from_me: Some(from_me),
158                id: Some(poll_msg_id.to_string()),
159                participant: if chat_jid.is_group() {
160                    Some(poll_creator_jid.to_string())
161                } else {
162                    None
163                },
164            }),
165            vote: buffa::MessageField::some(wa::message::PollEncValue {
166                enc_payload: Some(enc_payload),
167                enc_iv: Some(iv.to_vec()),
168            }),
169            // WA Web's GeneratePollVoteMessageProto never sets metadata; a Some(empty)
170            // submessage emits a stray `1A 00` (tag 3) on the wire. Omit it.
171            metadata: buffa::MessageField::none(),
172            sender_timestamp_ms: Some(wacore::time::now_millis()),
173        };
174
175        let message = wa::Message {
176            poll_update_message: buffa::MessageField::some(poll_update),
177            ..Default::default()
178        };
179
180        Ok(self.client.send_message(chat_jid, message).await?)
181    }
182
183    /// The voter (self) JID keys the vote's HKDF/AAD, so it must use the poll
184    /// creator's namespace, else the host derives a different key. Own LID for
185    /// LID-addressed polls, own PN otherwise, falling back to PN when our LID
186    /// isn't known yet. Matches WA Web `WAWebAddonEncryption`.
187    async fn resolve_voter_jid(
188        &self,
189        poll_creator_jid: &Jid,
190        own_pn: &Jid,
191        poll_msg_id: &str,
192    ) -> Jid {
193        if !poll_creator_jid.is_lid() {
194            return own_pn.clone();
195        }
196        match self.client.lid() {
197            Some(lid) => lid.to_non_ad(),
198            None => {
199                log::warn!(
200                    "Poll {poll_msg_id} is LID-addressed but own LID is unknown; \
201                     falling back to PN voter (host may fail to decrypt)"
202                );
203                own_pn.clone()
204            }
205        }
206    }
207
208    /// Selected option hashes (32 bytes each). Retries under the opposite
209    /// namespace (LID/PN) when a counterpart is known, so votes authored across
210    /// the LID migration still open. Mirrors WA Web `WAWebAddonEncryption`.
211    pub async fn decrypt_vote(
212        &self,
213        ciphertext: PollVoteCiphertext<'_>,
214        message_secret: &[u8],
215        poll_msg_id: &str,
216        poll_creator_jid: &Jid,
217        voter_jid: &Jid,
218    ) -> Result<Vec<Vec<u8>>, PollError> {
219        let creator = poll_creator_jid.to_non_ad();
220        let voter = voter_jid.to_non_ad();
221        let creator_str = creator.to_string();
222        let voter_str = voter.to_string();
223
224        let creator_alt = self.swapped_user(&creator).await;
225        let voter_alt = self.swapped_user(&voter).await;
226        let fallback = Self::build_fallback(&creator_alt, &voter_alt);
227
228        poll::decrypt_poll_vote_with_fallback(
229            ciphertext,
230            message_secret,
231            poll_msg_id,
232            poll::PollVoteAddressing {
233                poll_creator_jid: &creator_str,
234                voter_jid: &voter_str,
235            },
236            fallback,
237        )
238        .map_err(PollError::Crypto)
239    }
240
241    /// Non-AD LID/PN counterpart of a user JID, or `None` when unmapped.
242    async fn swapped_user(&self, jid: &Jid) -> Option<String> {
243        self.client
244            .swap_pn_lid_namespace(jid)
245            .await
246            .map(|j| j.to_non_ad_string())
247    }
248
249    /// Fallback pair only when both JIDs have a counterpart, keeping it
250    /// homogeneous (LID or PN, never mixed) like WA Web's `decryptAddOn`.
251    fn build_fallback<'b>(
252        creator_alt: &'b Option<String>,
253        voter_alt: &'b Option<String>,
254    ) -> Option<poll::PollVoteAddressing<'b>> {
255        match (creator_alt, voter_alt) {
256            (Some(c), Some(v)) => Some(poll::PollVoteAddressing {
257                poll_creator_jid: c,
258                voter_jid: v,
259            }),
260            _ => None,
261        }
262    }
263
264    /// Decrypts each vote and tallies per-option results.
265    /// Later votes from the same voter replace earlier ones (last-vote-wins).
266    /// `votes` should be ordered oldest-first.
267    ///
268    /// Dedupes voters by their canonical (LID-preferred) identity so a voter who
269    /// re-votes under the other namespace after migrating replaces, rather than
270    /// duplicates, their earlier vote.
271    pub async fn aggregate_votes(
272        &self,
273        poll_options: &[String],
274        votes: &[(&Jid, PollVoteCiphertext<'_>)],
275        message_secret: &[u8],
276        poll_msg_id: &str,
277        poll_creator_jid: &Jid,
278    ) -> Result<Vec<PollOptionResult>, PollError> {
279        let option_hashes: Vec<([u8; 32], &str)> = poll_options
280            .iter()
281            .map(|name| (poll::compute_option_hash(name), name.as_str()))
282            .collect();
283
284        // Creator addressing is constant across voters; resolve its counterpart once.
285        let creator = poll_creator_jid.to_non_ad();
286        let creator_str = creator.to_string();
287        let creator_alt = self.swapped_user(&creator).await;
288
289        // Keyed by canonical (LID-preferred) identity. The optional display JID
290        // is only stored when it differs from the key. Last-vote-wins.
291        let mut latest_votes: HashMap<String, (Option<String>, Vec<usize>)> =
292            HashMap::with_capacity(votes.len());
293        for (voter_jid, ciphertext) in votes {
294            let voter = voter_jid.to_non_ad();
295            let voter_str = voter.to_string();
296            let voter_alt = self.swapped_user(&voter).await;
297            let fallback = Self::build_fallback(&creator_alt, &voter_alt);
298            let canonical_voter = if voter.is_lid() {
299                voter_str.clone()
300            } else {
301                voter_alt.clone().unwrap_or_else(|| voter_str.clone())
302            };
303            match poll::decrypt_poll_vote_with_fallback(
304                *ciphertext,
305                message_secret,
306                poll_msg_id,
307                poll::PollVoteAddressing {
308                    poll_creator_jid: &creator_str,
309                    voter_jid: &voter_str,
310                },
311                fallback,
312            ) {
313                Ok(hashes) => {
314                    let display_jid = if voter.is_lid() {
315                        None
316                    } else if voter_alt.is_some() {
317                        Some(voter_str)
318                    } else {
319                        None
320                    };
321                    if hashes.is_empty() {
322                        latest_votes.remove(canonical_voter.as_str());
323                    } else {
324                        let selected_indices: Vec<usize> = hashes
325                            .iter()
326                            .filter_map(|h| {
327                                <[u8; 32]>::try_from(h.as_slice()).ok().and_then(|arr| {
328                                    option_hashes.iter().position(|(oh, _)| *oh == arr)
329                                })
330                            })
331                            .collect();
332                        latest_votes.insert(canonical_voter, (display_jid, selected_indices));
333                    }
334                }
335                Err(e) => {
336                    log::warn!("Failed to decrypt vote from {voter_jid}: {e}");
337                }
338            }
339        }
340
341        let mut results: Vec<PollOptionResult> = poll_options
342            .iter()
343            .map(|name| PollOptionResult {
344                name: name.clone(),
345                voters: Vec::new(),
346            })
347            .collect();
348
349        for (canonical_jid, (display_jid, selected_indices)) in latest_votes {
350            let display_jid = display_jid.unwrap_or(canonical_jid);
351            if let Some((last_idx, prefix_indices)) = selected_indices.split_last() {
352                for idx in prefix_indices {
353                    results[*idx].voters.push(display_jid.clone());
354                }
355                results[*last_idx].voters.push(display_jid);
356            }
357        }
358
359        Ok(results)
360    }
361}
362
363impl Client {
364    pub fn polls(&self) -> Polls<'_> {
365        Polls::new(self)
366    }
367}
368
369/// Validate inputs and build a `PollCreationMessage`. A `Some(correct_index)`
370/// produces a QUIZ; `None` produces a regular poll. Mirrors WA Web's
371/// `GeneratePollCreationMessageProto` + `validatePollCreationMessage`, which require
372/// `correctAnswer` iff `pollType == QUIZ`, with the chosen option carrying BOTH its
373/// name and hash (even for text polls).
374fn build_poll_creation_message(
375    name: &str,
376    options: &[String],
377    selectable_count: u32,
378    correct_index: Option<usize>,
379) -> Result<wa::message::PollCreationMessage, PollError> {
380    if options.len() < 2 {
381        return Err(PollError::InvalidPoll(
382            "poll must have at least 2 options".into(),
383        ));
384    }
385    if options.len() > 12 {
386        return Err(PollError::InvalidPoll(
387            "polls can have a maximum of 12 options".into(),
388        ));
389    }
390    if selectable_count < 1 || selectable_count > options.len() as u32 {
391        return Err(PollError::InvalidPoll(format!(
392            "selectable_count must be between 1 and {} (got {selectable_count})",
393            options.len()
394        )));
395    }
396
397    // Duplicate names would produce identical SHA-256 hashes, making votes indistinguishable
398    let mut seen = std::collections::HashSet::new();
399    for opt in options {
400        if !seen.insert(opt) {
401            return Err(PollError::InvalidPoll(format!(
402                "duplicate option name: {opt}"
403            )));
404        }
405    }
406
407    let (poll_type, correct_answer) = match correct_index {
408        Some(idx) => {
409            let correct = options.get(idx).ok_or_else(|| {
410                PollError::InvalidPoll(format!(
411                    "correct_index {idx} out of range (poll has {} options)",
412                    options.len()
413                ))
414            })?;
415            let answer = wa::message::poll_creation_message::Option {
416                option_name: Some(correct.clone()),
417                // optionHash is the lowercase hex of SHA-256(name), matching WA Web's
418                // createOptionHashHexFromString (the proto field is a string, not bytes).
419                option_hash: Some(hex::encode(poll::compute_option_hash(correct))),
420            };
421            (
422                Some(wa::message::PollType::QUIZ),
423                buffa::MessageField::some(answer),
424            )
425        }
426        None => (None, buffa::MessageField::none()),
427    };
428
429    let poll_options: Vec<wa::message::poll_creation_message::Option> = options
430        .iter()
431        .map(|name| wa::message::poll_creation_message::Option {
432            option_name: Some(name.clone()),
433            option_hash: None,
434        })
435        .collect();
436
437    Ok(wa::message::PollCreationMessage {
438        enc_key: None,
439        name: Some(name.to_string()),
440        options: poll_options,
441        selectable_options_count: Some(selectable_count),
442        context_info: buffa::MessageField::none(),
443        // WA Web's GeneratePollCreationMessageProto always sets pollContentType
444        // (TEXT=1 for a normal poll); omitting it drops a field the real client
445        // always emits.
446        poll_content_type: Some(wa::message::PollContentType::TEXT),
447        poll_type,
448        correct_answer,
449        ..Default::default()
450    })
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::lid_pn_cache::LearningSource;
457    use crate::store::commands::DeviceCommand;
458    use crate::test_utils::create_test_client;
459    use std::sync::Arc;
460
461    // poll/quiz message construction (build_poll_creation_message)
462
463    #[test]
464    fn regular_poll_has_no_quiz_fields() {
465        let options = vec!["A".to_string(), "B".to_string(), "C".to_string()];
466        let msg = build_poll_creation_message("Q?", &options, 2, None).unwrap();
467        assert_eq!(msg.poll_type, None);
468        assert!(msg.correct_answer.is_unset());
469        assert_eq!(msg.selectable_options_count, Some(2));
470        assert_eq!(
471            msg.poll_content_type,
472            Some(wa::message::PollContentType::TEXT)
473        );
474        assert_eq!(msg.options.len(), 3);
475        assert!(msg.options.iter().all(|o| o.option_hash.is_none()));
476    }
477
478    #[test]
479    fn quiz_sets_poll_type_and_correct_answer() {
480        let options = vec!["A".to_string(), "B".to_string(), "C".to_string()];
481        let msg = build_poll_creation_message("Q?", &options, 1, Some(1)).unwrap();
482        assert_eq!(msg.poll_type, Some(wa::message::PollType::QUIZ));
483        let answer = msg
484            .correct_answer
485            .as_option()
486            .expect("quiz must carry a correct answer");
487        // WA Web sets BOTH name and hash on the chosen option, even for text polls;
488        // the hash is the lowercase hex of SHA-256(name).
489        assert_eq!(answer.option_name.as_deref(), Some("B"));
490        let expected_hash = hex::encode(poll::compute_option_hash("B"));
491        assert_eq!(answer.option_hash.as_deref(), Some(expected_hash.as_str()));
492    }
493
494    #[test]
495    fn quiz_rejects_out_of_range_correct_index() {
496        let options = vec!["A".to_string(), "B".to_string()];
497        assert!(build_poll_creation_message("Q?", &options, 1, Some(5)).is_err());
498    }
499
500    // encrypt-side voter selection (resolve_voter_jid)
501
502    #[tokio::test]
503    async fn voter_is_pn_when_poll_creator_is_pn() {
504        let client: Arc<Client> = create_test_client().await;
505        let own_pn = Jid::pn("5511999999999");
506        let creator = Jid::pn("5511777777777");
507
508        let voter = client
509            .polls()
510            .resolve_voter_jid(&creator, &own_pn, "POLLID")
511            .await;
512        assert_eq!(voter, own_pn);
513    }
514
515    #[tokio::test]
516    async fn voter_is_own_lid_when_poll_creator_is_lid() {
517        let client: Arc<Client> = create_test_client().await;
518        let own_lid: Jid = "888000888000888:3@lid".parse().unwrap();
519        client
520            .persistence_manager
521            .process_command(DeviceCommand::SetLid(Some(own_lid.clone())))
522            .await;
523
524        let own_pn = Jid::pn("5511999999999");
525        let creator = Jid::lid("111000111000111");
526
527        let voter = client
528            .polls()
529            .resolve_voter_jid(&creator, &own_pn, "POLLID")
530            .await;
531        assert!(voter.is_lid(), "voter must be LID-addressed in a LID poll");
532        assert_eq!(voter.user, own_lid.user);
533        assert_eq!(voter, own_lid.to_non_ad());
534    }
535
536    #[tokio::test]
537    async fn voter_falls_back_to_pn_when_own_lid_unknown() {
538        let client: Arc<Client> = create_test_client().await;
539        // No SetLid, so lid() is None.
540        let own_pn = Jid::pn("5511999999999");
541        let creator = Jid::lid("111000111000111");
542
543        let voter = client
544            .polls()
545            .resolve_voter_jid(&creator, &own_pn, "POLLID")
546            .await;
547        assert_eq!(voter, own_pn);
548    }
549
550    // decrypt-side LID/PN fallback
551
552    /// A vote encrypted under the PN pair must still decrypt when the consumer
553    /// only knows the LID JIDs, via the namespace-swap fallback.
554    #[tokio::test]
555    async fn decrypt_vote_recovers_when_fed_lid_but_encrypted_under_pn() {
556        let client: Arc<Client> = create_test_client().await;
557        let secret = [0x21u8; 32];
558        let stanza_id = "3EB0POLLVOTE";
559
560        let creator_pn = "5511777777777";
561        let creator_lid = "111000111000111";
562        let voter_pn = "5511888888888";
563        let voter_lid = "222000222000222";
564
565        client
566            .add_lid_pn_mapping(creator_lid, creator_pn, LearningSource::Usync)
567            .await
568            .unwrap();
569        client
570            .add_lid_pn_mapping(voter_lid, voter_pn, LearningSource::Usync)
571            .await
572            .unwrap();
573
574        let hashes = vec![poll::compute_option_hash("Yes").to_vec()];
575        let (enc, iv) = poll::encrypt_poll_vote_with_secret(
576            &hashes,
577            &secret,
578            stanza_id,
579            &Jid::pn(creator_pn).to_string(),
580            &Jid::pn(voter_pn).to_string(),
581        )
582        .unwrap();
583
584        // Consumer feeds LID JIDs; primary (LID) fails, fallback swaps to PN.
585        let out = client
586            .polls()
587            .decrypt_vote(
588                PollVoteCiphertext {
589                    enc_payload: &enc,
590                    enc_iv: &iv,
591                },
592                &secret,
593                stanza_id,
594                &Jid::lid(creator_lid),
595                &Jid::lid(voter_lid),
596            )
597            .await
598            .expect("fallback should rescue the PN-encrypted vote");
599        assert_eq!(out, hashes);
600    }
601
602    /// Without a known mapping there is no fallback pair, so a LID-fed decrypt
603    /// of a PN-encrypted vote must fail rather than silently mis-decrypt.
604    #[tokio::test]
605    async fn decrypt_vote_fails_without_mapping() {
606        let client: Arc<Client> = create_test_client().await;
607        let secret = [0x21u8; 32];
608        let stanza_id = "3EB0POLLVOTE";
609
610        let (enc, iv) = poll::encrypt_poll_vote_with_secret(
611            &[poll::compute_option_hash("Yes").to_vec()],
612            &secret,
613            stanza_id,
614            &Jid::pn("5511777777777").to_string(),
615            &Jid::pn("5511888888888").to_string(),
616        )
617        .unwrap();
618
619        let res = client
620            .polls()
621            .decrypt_vote(
622                PollVoteCiphertext {
623                    enc_payload: &enc,
624                    enc_iv: &iv,
625                },
626                &secret,
627                stanza_id,
628                &Jid::lid("111000111000111"),
629                &Jid::lid("222000222000222"),
630            )
631            .await;
632        assert!(res.is_err(), "no mapping → no fallback → must not decrypt");
633    }
634
635    #[tokio::test]
636    async fn aggregate_votes_recovers_across_addressing() {
637        let client: Arc<Client> = create_test_client().await;
638        let secret = [0x31u8; 32];
639        let stanza_id = "3EB0AGG";
640        let options = vec!["Yes".to_string(), "No".to_string()];
641
642        let creator_pn = "5511777777777";
643        let creator_lid = "111000111000111";
644        let voter_pn = "5511888888888";
645        let voter_lid = "222000222000222";
646
647        client
648            .add_lid_pn_mapping(creator_lid, creator_pn, LearningSource::Usync)
649            .await
650            .unwrap();
651        client
652            .add_lid_pn_mapping(voter_lid, voter_pn, LearningSource::Usync)
653            .await
654            .unwrap();
655
656        let (enc, iv) = poll::encrypt_poll_vote_with_secret(
657            &[poll::compute_option_hash("Yes").to_vec()],
658            &secret,
659            stanza_id,
660            &Jid::pn(creator_pn).to_string(),
661            &Jid::pn(voter_pn).to_string(),
662        )
663        .unwrap();
664
665        let voter_lid_jid = Jid::lid(voter_lid);
666        let votes: Vec<(&Jid, PollVoteCiphertext)> = vec![(
667            &voter_lid_jid,
668            PollVoteCiphertext {
669                enc_payload: &enc,
670                enc_iv: &iv,
671            },
672        )];
673
674        let results = client
675            .polls()
676            .aggregate_votes(&options, &votes, &secret, stanza_id, &Jid::lid(creator_lid))
677            .await
678            .unwrap();
679
680        let yes = results.iter().find(|r| r.name == "Yes").unwrap();
681        assert_eq!(yes.voters.len(), 1, "the LID voter's 'Yes' must be tallied");
682        let no = results.iter().find(|r| r.name == "No").unwrap();
683        assert!(no.voters.is_empty());
684    }
685
686    /// Same voter votes as PN then re-votes as LID after migrating. The
687    /// canonical key must collapse them so last-vote-wins replaces, not
688    /// duplicates, the earlier namespace's entry.
689    #[tokio::test]
690    async fn aggregate_dedupes_revote_across_namespace() {
691        let client: Arc<Client> = create_test_client().await;
692        let secret = [0x41u8; 32];
693        let stanza_id = "3EB0REVOTE";
694        let options = vec!["Yes".to_string(), "No".to_string()];
695
696        let creator_pn = "5511777777777";
697        let creator_lid = "111000111000111";
698        let voter_pn = "5511888888888";
699        let voter_lid = "222000222000222";
700        client
701            .add_lid_pn_mapping(creator_lid, creator_pn, LearningSource::Usync)
702            .await
703            .unwrap();
704        client
705            .add_lid_pn_mapping(voter_lid, voter_pn, LearningSource::Usync)
706            .await
707            .unwrap();
708
709        // Oldest-first: PN "Yes", then LID "No".
710        let (enc_pn, iv_pn) = poll::encrypt_poll_vote_with_secret(
711            &[poll::compute_option_hash("Yes").to_vec()],
712            &secret,
713            stanza_id,
714            &Jid::pn(creator_pn).to_string(),
715            &Jid::pn(voter_pn).to_string(),
716        )
717        .unwrap();
718        let (enc_lid, iv_lid) = poll::encrypt_poll_vote_with_secret(
719            &[poll::compute_option_hash("No").to_vec()],
720            &secret,
721            stanza_id,
722            &Jid::lid(creator_lid).to_string(),
723            &Jid::lid(voter_lid).to_string(),
724        )
725        .unwrap();
726
727        let voter_pn_jid = Jid::pn(voter_pn);
728        let voter_lid_jid = Jid::lid(voter_lid);
729        let votes: Vec<(&Jid, PollVoteCiphertext)> = vec![
730            (
731                &voter_pn_jid,
732                PollVoteCiphertext {
733                    enc_payload: &enc_pn,
734                    enc_iv: &iv_pn,
735                },
736            ),
737            (
738                &voter_lid_jid,
739                PollVoteCiphertext {
740                    enc_payload: &enc_lid,
741                    enc_iv: &iv_lid,
742                },
743            ),
744        ];
745
746        let results = client
747            .polls()
748            .aggregate_votes(&options, &votes, &secret, stanza_id, &Jid::lid(creator_lid))
749            .await
750            .unwrap();
751
752        let yes = results.iter().find(|r| r.name == "Yes").unwrap();
753        let no = results.iter().find(|r| r.name == "No").unwrap();
754        assert!(yes.voters.is_empty(), "the PN 'Yes' must be replaced");
755        assert_eq!(no.voters.len(), 1, "only the re-vote should count, once");
756    }
757
758    /// A clear-vote received under the other namespace must remove the prior
759    /// vote, not leave a stale entry keyed by the original namespace.
760    #[tokio::test]
761    async fn aggregate_clears_vote_across_namespace() {
762        let client: Arc<Client> = create_test_client().await;
763        let secret = [0x51u8; 32];
764        let stanza_id = "3EB0CLEAR";
765        let options = vec!["Yes".to_string(), "No".to_string()];
766
767        let creator_pn = "5511777777777";
768        let creator_lid = "111000111000111";
769        let voter_pn = "5511888888888";
770        let voter_lid = "222000222000222";
771        client
772            .add_lid_pn_mapping(creator_lid, creator_pn, LearningSource::Usync)
773            .await
774            .unwrap();
775        client
776            .add_lid_pn_mapping(voter_lid, voter_pn, LearningSource::Usync)
777            .await
778            .unwrap();
779
780        let (enc_pn, iv_pn) = poll::encrypt_poll_vote_with_secret(
781            &[poll::compute_option_hash("Yes").to_vec()],
782            &secret,
783            stanza_id,
784            &Jid::pn(creator_pn).to_string(),
785            &Jid::pn(voter_pn).to_string(),
786        )
787        .unwrap();
788        // Empty selection = clear, authored under the LID pair after migrating.
789        let (enc_clear, iv_clear) = poll::encrypt_poll_vote_with_secret(
790            &[],
791            &secret,
792            stanza_id,
793            &Jid::lid(creator_lid).to_string(),
794            &Jid::lid(voter_lid).to_string(),
795        )
796        .unwrap();
797
798        let voter_pn_jid = Jid::pn(voter_pn);
799        let voter_lid_jid = Jid::lid(voter_lid);
800        let votes: Vec<(&Jid, PollVoteCiphertext)> = vec![
801            (
802                &voter_pn_jid,
803                PollVoteCiphertext {
804                    enc_payload: &enc_pn,
805                    enc_iv: &iv_pn,
806                },
807            ),
808            (
809                &voter_lid_jid,
810                PollVoteCiphertext {
811                    enc_payload: &enc_clear,
812                    enc_iv: &iv_clear,
813                },
814            ),
815        ];
816
817        let results = client
818            .polls()
819            .aggregate_votes(&options, &votes, &secret, stanza_id, &Jid::lid(creator_lid))
820            .await
821            .unwrap();
822
823        assert!(
824            results.iter().all(|r| r.voters.is_empty()),
825            "the LID clear-vote must remove the earlier PN 'Yes'"
826        );
827    }
828}