1use 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#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum PollError {
19 #[error("{0}")]
21 Send(#[from] SendError),
22 #[error("invalid poll: {0}")]
25 InvalidPoll(String),
26 #[error("client is not logged in")]
28 NotLoggedIn,
29 #[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 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 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 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 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 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 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 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 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 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 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 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 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
369fn 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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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}