1use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use crate::crdt::EntityType;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InviteStatus {
16 Pending,
18 Accepted,
20 Rejected,
22 Expired,
24 Revoked,
26}
27
28impl InviteStatus {
29 pub fn is_terminal(self) -> bool {
31 !matches!(self, InviteStatus::Pending)
32 }
33
34 pub fn all() -> &'static [InviteStatus] {
36 &[
37 InviteStatus::Pending,
38 InviteStatus::Accepted,
39 InviteStatus::Rejected,
40 InviteStatus::Expired,
41 InviteStatus::Revoked,
42 ]
43 }
44}
45
46impl std::fmt::Display for InviteStatus {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 InviteStatus::Pending => write!(f, "pending"),
50 InviteStatus::Accepted => write!(f, "accepted"),
51 InviteStatus::Rejected => write!(f, "rejected"),
52 InviteStatus::Expired => write!(f, "expired"),
53 InviteStatus::Revoked => write!(f, "revoked"),
54 }
55 }
56}
57
58impl std::str::FromStr for InviteStatus {
59 type Err = InviteParseError;
60
61 fn from_str(s: &str) -> Result<Self, Self::Err> {
62 match s.to_lowercase().as_str() {
63 "pending" => Ok(InviteStatus::Pending),
64 "accepted" => Ok(InviteStatus::Accepted),
65 "rejected" => Ok(InviteStatus::Rejected),
66 "expired" => Ok(InviteStatus::Expired),
67 "revoked" => Ok(InviteStatus::Revoked),
68 _ => Err(InviteParseError::InvalidStatus(s.to_string())),
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum InviteParseError {
76 InvalidStatus(String),
78 InvalidFourWords(String),
80}
81
82impl std::fmt::Display for InviteParseError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 InviteParseError::InvalidStatus(s) => {
86 write!(
87 f,
88 "invalid invite status '{}': expected pending, accepted, rejected, expired, or revoked",
89 s
90 )
91 }
92 InviteParseError::InvalidFourWords(s) => {
93 write!(
94 f,
95 "invalid four-word identity '{}': expected format 'word-word-word-word'",
96 s
97 )
98 }
99 }
100 }
101}
102
103impl std::error::Error for InviteParseError {}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Invite {
112 pub id: String,
114
115 pub creator_id: String,
117
118 pub recipient_id: String,
120
121 pub entity_id: String,
123
124 pub entity_type: EntityType,
126
127 pub role: String,
129
130 pub message: Option<String>,
132
133 pub status: InviteStatus,
135
136 pub created_at: i64,
138
139 pub expires_at: Option<i64>,
141
142 pub resolved_at: Option<i64>,
144
145 pub resolved_by: Option<String>,
147}
148
149impl Invite {
150 pub fn new(
182 creator_id: String,
183 recipient_id: String,
184 entity_id: String,
185 entity_type: EntityType,
186 role: String,
187 message: Option<String>,
188 expires_in_hours: Option<u32>,
189 ) -> Self {
190 let now = Utc::now();
191 let created_at = now.timestamp_millis();
192 let expires_at =
193 expires_in_hours.map(|h| (now + Duration::hours(i64::from(h))).timestamp_millis());
194
195 Self {
196 id: Uuid::new_v4().to_string(),
197 creator_id,
198 recipient_id,
199 entity_id,
200 entity_type,
201 role,
202 message,
203 status: InviteStatus::Pending,
204 created_at,
205 expires_at,
206 resolved_at: None,
207 resolved_by: None,
208 }
209 }
210
211 #[cfg(test)]
216 pub fn with_id(id: String, mut base: Self) -> Self {
217 base.id = id;
218 base
219 }
220
221 pub fn set_id(mut self, id: String) -> Self {
225 self.id = id;
226 self
227 }
228
229 pub fn is_pending(&self) -> bool {
231 self.status == InviteStatus::Pending
232 }
233
234 pub fn is_accepted(&self) -> bool {
236 self.status == InviteStatus::Accepted
237 }
238
239 pub fn is_resolved(&self) -> bool {
241 self.status.is_terminal()
242 }
243
244 pub fn is_expired(&self) -> bool {
246 self.is_expired_at(Utc::now())
247 }
248
249 pub fn is_expired_at(&self, at: DateTime<Utc>) -> bool {
251 if let Some(expires_at) = self.expires_at {
252 at.timestamp_millis() > expires_at
253 } else {
254 false
255 }
256 }
257
258 pub fn is_valid(&self) -> bool {
260 self.is_valid_at(Utc::now())
261 }
262
263 pub fn is_valid_at(&self, at: DateTime<Utc>) -> bool {
265 self.is_pending() && !self.is_expired_at(at)
266 }
267
268 pub fn accept(&mut self, acceptor_id: &str) -> Result<(), InviteActionError> {
272 self.accept_at(acceptor_id, Utc::now())
273 }
274
275 pub fn accept_at(
277 &mut self,
278 acceptor_id: &str,
279 at: DateTime<Utc>,
280 ) -> Result<(), InviteActionError> {
281 if self.recipient_id != acceptor_id {
282 return Err(InviteActionError::NotRecipient {
283 expected: self.recipient_id.clone(),
284 actual: acceptor_id.to_string(),
285 });
286 }
287
288 if !self.is_pending() {
289 return Err(InviteActionError::AlreadyResolved(self.status));
290 }
291
292 if self.is_expired_at(at) {
293 return Err(InviteActionError::Expired);
294 }
295
296 self.status = InviteStatus::Accepted;
297 self.resolved_at = Some(at.timestamp_millis());
298 self.resolved_by = Some(acceptor_id.to_string());
299 Ok(())
300 }
301
302 pub fn reject(&mut self, rejector_id: &str) -> Result<(), InviteActionError> {
306 self.reject_at(rejector_id, Utc::now())
307 }
308
309 pub fn reject_at(
311 &mut self,
312 rejector_id: &str,
313 at: DateTime<Utc>,
314 ) -> Result<(), InviteActionError> {
315 if self.recipient_id != rejector_id {
316 return Err(InviteActionError::NotRecipient {
317 expected: self.recipient_id.clone(),
318 actual: rejector_id.to_string(),
319 });
320 }
321
322 if !self.is_pending() {
323 return Err(InviteActionError::AlreadyResolved(self.status));
324 }
325
326 self.status = InviteStatus::Rejected;
328 self.resolved_at = Some(at.timestamp_millis());
329 self.resolved_by = Some(rejector_id.to_string());
330 Ok(())
331 }
332
333 pub fn revoke(&mut self, revoker_id: &str) -> Result<(), InviteActionError> {
337 self.revoke_at(revoker_id, Utc::now())
338 }
339
340 pub fn revoke_at(
342 &mut self,
343 revoker_id: &str,
344 at: DateTime<Utc>,
345 ) -> Result<(), InviteActionError> {
346 if !self.is_pending() {
347 return Err(InviteActionError::AlreadyResolved(self.status));
348 }
349
350 self.status = InviteStatus::Revoked;
351 self.resolved_at = Some(at.timestamp_millis());
352 self.resolved_by = Some(revoker_id.to_string());
353 Ok(())
354 }
355
356 pub fn mark_expired(&mut self) -> Result<(), InviteActionError> {
360 self.mark_expired_at(Utc::now())
361 }
362
363 pub fn mark_expired_at(&mut self, at: DateTime<Utc>) -> Result<(), InviteActionError> {
365 if !self.is_pending() {
366 return Err(InviteActionError::AlreadyResolved(self.status));
367 }
368
369 self.status = InviteStatus::Expired;
370 self.resolved_at = Some(at.timestamp_millis());
371 Ok(())
372 }
373
374 pub fn created_at_datetime(&self) -> DateTime<Utc> {
376 DateTime::from_timestamp_millis(self.created_at).unwrap_or_else(Utc::now)
377 }
378
379 pub fn expires_at_datetime(&self) -> Option<DateTime<Utc>> {
381 self.expires_at.and_then(DateTime::from_timestamp_millis)
382 }
383
384 pub fn resolved_at_datetime(&self) -> Option<DateTime<Utc>> {
386 self.resolved_at.and_then(DateTime::from_timestamp_millis)
387 }
388
389 pub fn time_remaining(&self) -> Option<Duration> {
391 self.time_remaining_at(Utc::now())
392 }
393
394 pub fn time_remaining_at(&self, at: DateTime<Utc>) -> Option<Duration> {
396 self.expires_at_datetime().map(|expires| expires - at)
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum InviteActionError {
403 AlreadyResolved(InviteStatus),
405 Expired,
407 NotRecipient { expected: String, actual: String },
409}
410
411impl std::fmt::Display for InviteActionError {
412 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413 match self {
414 InviteActionError::AlreadyResolved(status) => {
415 write!(f, "invite already resolved with status: {}", status)
416 }
417 InviteActionError::Expired => write!(f, "invite has expired"),
418 InviteActionError::NotRecipient { expected, actual } => {
419 write!(
420 f,
421 "not the invite recipient: expected '{}', got '{}'",
422 expected, actual
423 )
424 }
425 }
426 }
427}
428
429impl std::error::Error for InviteActionError {}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn create_test_invite() -> Invite {
437 Invite::new(
438 "alice-brave-cloud-dawn".to_string(),
439 "bob-calm-river-east".to_string(),
440 "project-123".to_string(),
441 EntityType::Project,
442 "member".to_string(),
443 None,
444 None,
445 )
446 }
447
448 fn create_test_invite_with_expiry(hours: u32) -> Invite {
449 Invite::new(
450 "alice-brave-cloud-dawn".to_string(),
451 "bob-calm-river-east".to_string(),
452 "project-123".to_string(),
453 EntityType::Project,
454 "member".to_string(),
455 None,
456 Some(hours),
457 )
458 }
459
460 #[test]
465 fn test_new_invite_has_pending_status() {
466 let invite = create_test_invite();
467 assert_eq!(invite.status, InviteStatus::Pending);
468 assert!(invite.is_pending());
469 assert!(!invite.is_resolved());
470 }
471
472 #[test]
473 fn test_new_invite_has_unique_id() {
474 let invite1 = create_test_invite();
475 let invite2 = create_test_invite();
476 assert_ne!(invite1.id, invite2.id);
477 }
478
479 #[test]
480 fn test_new_invite_stores_creator_and_recipient() {
481 let invite = Invite::new(
482 "creator-one-two-three".to_string(),
483 "recipient-four-five-six".to_string(),
484 "entity-1".to_string(),
485 EntityType::Group,
486 "viewer".to_string(),
487 Some("Welcome!".to_string()),
488 None,
489 );
490
491 assert_eq!(invite.creator_id, "creator-one-two-three");
492 assert_eq!(invite.recipient_id, "recipient-four-five-six");
493 assert_eq!(invite.entity_id, "entity-1");
494 assert_eq!(invite.entity_type, EntityType::Group);
495 assert_eq!(invite.role, "viewer");
496 assert_eq!(invite.message, Some("Welcome!".to_string()));
497 }
498
499 #[test]
500 fn test_new_invite_sets_created_at() {
501 let before = Utc::now().timestamp_millis();
502 let invite = create_test_invite();
503 let after = Utc::now().timestamp_millis();
504
505 assert!(invite.created_at >= before);
506 assert!(invite.created_at <= after);
507 }
508
509 #[test]
510 fn test_new_invite_without_expiry() {
511 let invite = create_test_invite();
512 assert!(invite.expires_at.is_none());
513 assert!(!invite.is_expired());
514 }
515
516 #[test]
517 fn test_new_invite_with_expiry() {
518 let invite = create_test_invite_with_expiry(24);
519 assert!(invite.expires_at.is_some());
520
521 let expected_expiry = invite.created_at + (24 * 60 * 60 * 1000);
522 assert_eq!(invite.expires_at.unwrap(), expected_expiry);
523 }
524
525 #[test]
526 fn test_with_id_sets_custom_id() {
527 let base = Invite::new(
528 "creator-a-b-c".to_string(),
529 "recipient-d-e-f".to_string(),
530 "entity-1".to_string(),
531 EntityType::Channel,
532 "member".to_string(),
533 None,
534 None,
535 );
536 let invite = Invite::with_id("custom-id-123".to_string(), base);
537
538 assert_eq!(invite.id, "custom-id-123");
539 }
540
541 #[test]
542 fn test_set_id_builder_pattern() {
543 let invite = Invite::new(
544 "creator-a-b-c".to_string(),
545 "recipient-d-e-f".to_string(),
546 "entity-1".to_string(),
547 EntityType::Channel,
548 "member".to_string(),
549 None,
550 None,
551 )
552 .set_id("my-custom-id".to_string());
553
554 assert_eq!(invite.id, "my-custom-id");
555 }
556
557 #[test]
562 fn test_is_expired_before_expiry_time() {
563 let invite = create_test_invite_with_expiry(24);
564
565 let at_creation = invite.created_at_datetime();
567 assert!(!invite.is_expired_at(at_creation));
568 }
569
570 #[test]
571 fn test_is_expired_after_expiry_time() {
572 let invite = create_test_invite_with_expiry(1); let two_hours_later = invite.created_at_datetime() + Duration::hours(2);
576 assert!(invite.is_expired_at(two_hours_later));
577 }
578
579 #[test]
580 fn test_is_expired_exactly_at_expiry() {
581 let invite = create_test_invite_with_expiry(1);
582
583 let at_expiry = invite.expires_at_datetime().unwrap();
585 assert!(!invite.is_expired_at(at_expiry));
586
587 let just_after = at_expiry + Duration::milliseconds(1);
589 assert!(invite.is_expired_at(just_after));
590 }
591
592 #[test]
593 fn test_is_valid_checks_both_pending_and_expiry() {
594 let invite = create_test_invite_with_expiry(1);
595
596 let at_creation = invite.created_at_datetime();
598 assert!(invite.is_valid_at(at_creation));
599
600 let after_expiry = invite.created_at_datetime() + Duration::hours(2);
602 assert!(!invite.is_valid_at(after_expiry));
603 }
604
605 #[test]
606 fn test_time_remaining() {
607 let invite = create_test_invite_with_expiry(24);
608 let at_creation = invite.created_at_datetime();
609
610 let remaining = invite.time_remaining_at(at_creation).unwrap();
611 assert!(remaining.num_hours() == 24 || remaining.num_hours() == 23);
613 }
614
615 #[test]
616 fn test_time_remaining_none_without_expiry() {
617 let invite = create_test_invite();
618 assert!(invite.time_remaining().is_none());
619 }
620
621 #[test]
626 fn test_accept_by_recipient_succeeds() {
627 let mut invite = create_test_invite();
628
629 let result = invite.accept("bob-calm-river-east");
630 assert!(result.is_ok());
631 assert_eq!(invite.status, InviteStatus::Accepted);
632 assert!(invite.is_accepted());
633 assert!(invite.is_resolved());
634 assert!(invite.resolved_at.is_some());
635 assert_eq!(invite.resolved_by, Some("bob-calm-river-east".to_string()));
636 }
637
638 #[test]
639 fn test_accept_by_non_recipient_fails() {
640 let mut invite = create_test_invite();
641
642 let result = invite.accept("charlie-wrong-person-here");
643 assert!(result.is_err());
644
645 match result {
646 Err(InviteActionError::NotRecipient { expected, actual }) => {
647 assert_eq!(expected, "bob-calm-river-east");
648 assert_eq!(actual, "charlie-wrong-person-here");
649 }
650 _ => panic!("Expected NotRecipient error"),
651 }
652
653 assert!(invite.is_pending());
655 }
656
657 #[test]
658 fn test_accept_already_accepted_fails() {
659 let mut invite = create_test_invite();
660 invite.accept("bob-calm-river-east").unwrap();
661
662 let result = invite.accept("bob-calm-river-east");
663 assert!(matches!(
664 result,
665 Err(InviteActionError::AlreadyResolved(InviteStatus::Accepted))
666 ));
667 }
668
669 #[test]
670 fn test_accept_already_rejected_fails() {
671 let mut invite = create_test_invite();
672 invite.reject("bob-calm-river-east").unwrap();
673
674 let result = invite.accept("bob-calm-river-east");
675 assert!(matches!(
676 result,
677 Err(InviteActionError::AlreadyResolved(InviteStatus::Rejected))
678 ));
679 }
680
681 #[test]
682 fn test_accept_expired_invite_fails() {
683 let mut invite = create_test_invite_with_expiry(1);
684 let after_expiry = invite.created_at_datetime() + Duration::hours(2);
685
686 let result = invite.accept_at("bob-calm-river-east", after_expiry);
687 assert!(matches!(result, Err(InviteActionError::Expired)));
688
689 assert!(invite.is_pending());
691 }
692
693 #[test]
698 fn test_reject_by_recipient_succeeds() {
699 let mut invite = create_test_invite();
700
701 let result = invite.reject("bob-calm-river-east");
702 assert!(result.is_ok());
703 assert_eq!(invite.status, InviteStatus::Rejected);
704 assert!(invite.is_resolved());
705 }
706
707 #[test]
708 fn test_reject_by_non_recipient_fails() {
709 let mut invite = create_test_invite();
710
711 let result = invite.reject("charlie-wrong-person-here");
712 assert!(matches!(
713 result,
714 Err(InviteActionError::NotRecipient { .. })
715 ));
716 }
717
718 #[test]
719 fn test_reject_already_resolved_fails() {
720 let mut invite = create_test_invite();
721 invite.accept("bob-calm-river-east").unwrap();
722
723 let result = invite.reject("bob-calm-river-east");
724 assert!(matches!(result, Err(InviteActionError::AlreadyResolved(_))));
725 }
726
727 #[test]
728 fn test_reject_expired_invite_succeeds() {
729 let mut invite = create_test_invite_with_expiry(1);
731 let after_expiry = invite.created_at_datetime() + Duration::hours(2);
732
733 let result = invite.reject_at("bob-calm-river-east", after_expiry);
735 assert!(result.is_ok());
736 assert_eq!(invite.status, InviteStatus::Rejected);
737 }
738
739 #[test]
744 fn test_revoke_by_creator_succeeds() {
745 let mut invite = create_test_invite();
746
747 let result = invite.revoke("alice-brave-cloud-dawn");
748 assert!(result.is_ok());
749 assert_eq!(invite.status, InviteStatus::Revoked);
750 assert!(invite.is_resolved());
751 assert_eq!(
752 invite.resolved_by,
753 Some("alice-brave-cloud-dawn".to_string())
754 );
755 }
756
757 #[test]
758 fn test_revoke_by_admin_succeeds() {
759 let mut invite = create_test_invite();
760
761 let result = invite.revoke("admin-other-person-here");
763 assert!(result.is_ok());
764 assert_eq!(invite.status, InviteStatus::Revoked);
765 }
766
767 #[test]
768 fn test_revoke_already_resolved_fails() {
769 let mut invite = create_test_invite();
770 invite.accept("bob-calm-river-east").unwrap();
771
772 let result = invite.revoke("alice-brave-cloud-dawn");
773 assert!(matches!(result, Err(InviteActionError::AlreadyResolved(_))));
774 }
775
776 #[test]
781 fn test_mark_expired_succeeds() {
782 let mut invite = create_test_invite();
783
784 let result = invite.mark_expired();
785 assert!(result.is_ok());
786 assert_eq!(invite.status, InviteStatus::Expired);
787 assert!(invite.is_resolved());
788 }
789
790 #[test]
791 fn test_mark_expired_already_resolved_fails() {
792 let mut invite = create_test_invite();
793 invite.accept("bob-calm-river-east").unwrap();
794
795 let result = invite.mark_expired();
796 assert!(matches!(result, Err(InviteActionError::AlreadyResolved(_))));
797 }
798
799 #[test]
804 fn test_status_is_terminal() {
805 assert!(!InviteStatus::Pending.is_terminal());
806 assert!(InviteStatus::Accepted.is_terminal());
807 assert!(InviteStatus::Rejected.is_terminal());
808 assert!(InviteStatus::Expired.is_terminal());
809 assert!(InviteStatus::Revoked.is_terminal());
810 }
811
812 #[test]
813 fn test_status_all() {
814 let all = InviteStatus::all();
815 assert_eq!(all.len(), 5);
816 assert!(all.contains(&InviteStatus::Pending));
817 assert!(all.contains(&InviteStatus::Accepted));
818 assert!(all.contains(&InviteStatus::Rejected));
819 assert!(all.contains(&InviteStatus::Expired));
820 assert!(all.contains(&InviteStatus::Revoked));
821 }
822
823 #[test]
824 fn test_status_display() {
825 assert_eq!(format!("{}", InviteStatus::Pending), "pending");
826 assert_eq!(format!("{}", InviteStatus::Accepted), "accepted");
827 assert_eq!(format!("{}", InviteStatus::Rejected), "rejected");
828 assert_eq!(format!("{}", InviteStatus::Expired), "expired");
829 assert_eq!(format!("{}", InviteStatus::Revoked), "revoked");
830 }
831
832 #[test]
833 fn test_status_from_str() {
834 assert_eq!(
835 "pending".parse::<InviteStatus>().unwrap(),
836 InviteStatus::Pending
837 );
838 assert_eq!(
839 "ACCEPTED".parse::<InviteStatus>().unwrap(),
840 InviteStatus::Accepted
841 );
842 assert_eq!(
843 "Rejected".parse::<InviteStatus>().unwrap(),
844 InviteStatus::Rejected
845 );
846 assert!("invalid".parse::<InviteStatus>().is_err());
847 }
848
849 #[test]
850 fn test_status_roundtrip() {
851 for status in InviteStatus::all() {
852 let s = status.to_string();
853 let parsed: InviteStatus = s.parse().unwrap();
854 assert_eq!(*status, parsed);
855 }
856 }
857
858 #[test]
863 fn test_invite_serialization_roundtrip() {
864 let invite = Invite::new(
865 "creator-a-b-c".to_string(),
866 "recipient-d-e-f".to_string(),
867 "entity-123".to_string(),
868 EntityType::Group,
869 "member".to_string(),
870 Some("Welcome to the group!".to_string()),
871 Some(48),
872 );
873
874 let json = serde_json::to_string(&invite).unwrap();
875 let parsed: Invite = serde_json::from_str(&json).unwrap();
876
877 assert_eq!(parsed.id, invite.id);
878 assert_eq!(parsed.creator_id, invite.creator_id);
879 assert_eq!(parsed.recipient_id, invite.recipient_id);
880 assert_eq!(parsed.entity_id, invite.entity_id);
881 assert_eq!(parsed.entity_type, invite.entity_type);
882 assert_eq!(parsed.role, invite.role);
883 assert_eq!(parsed.message, invite.message);
884 assert_eq!(parsed.status, invite.status);
885 assert_eq!(parsed.created_at, invite.created_at);
886 assert_eq!(parsed.expires_at, invite.expires_at);
887 }
888
889 #[test]
890 fn test_status_serialization() {
891 let status = InviteStatus::Pending;
892 let json = serde_json::to_string(&status).unwrap();
893 assert_eq!(json, "\"pending\"");
894
895 let parsed: InviteStatus = serde_json::from_str(&json).unwrap();
896 assert_eq!(parsed, status);
897 }
898
899 #[test]
904 fn test_created_at_datetime() {
905 let invite = create_test_invite();
906 let dt = invite.created_at_datetime();
907
908 let diff = (Utc::now() - dt).num_seconds().abs();
910 assert!(diff < 2);
911 }
912
913 #[test]
914 fn test_expires_at_datetime() {
915 let invite = create_test_invite_with_expiry(24);
916 let expires = invite.expires_at_datetime().unwrap();
917 let created = invite.created_at_datetime();
918
919 let diff = (expires - created).num_hours();
920 assert_eq!(diff, 24);
921 }
922
923 #[test]
924 fn test_resolved_at_datetime_none_before_resolution() {
925 let invite = create_test_invite();
926 assert!(invite.resolved_at_datetime().is_none());
927 }
928
929 #[test]
930 fn test_resolved_at_datetime_set_after_resolution() {
931 let mut invite = create_test_invite();
932 invite.accept("bob-calm-river-east").unwrap();
933
934 let resolved = invite.resolved_at_datetime();
935 assert!(resolved.is_some());
936 }
937
938 #[test]
943 fn test_invite_action_error_display() {
944 let err = InviteActionError::AlreadyResolved(InviteStatus::Accepted);
945 assert!(err.to_string().contains("already resolved"));
946 assert!(err.to_string().contains("accepted"));
947
948 let err = InviteActionError::Expired;
949 assert!(err.to_string().contains("expired"));
950
951 let err = InviteActionError::NotRecipient {
952 expected: "alice-a-b-c".to_string(),
953 actual: "bob-d-e-f".to_string(),
954 };
955 assert!(err.to_string().contains("alice-a-b-c"));
956 assert!(err.to_string().contains("bob-d-e-f"));
957 }
958
959 #[test]
960 fn test_invite_parse_error_display() {
961 let err = InviteParseError::InvalidStatus("unknown".to_string());
962 assert!(err.to_string().contains("unknown"));
963 assert!(err.to_string().contains("pending"));
964
965 let err = InviteParseError::InvalidFourWords("bad-format".to_string());
966 assert!(err.to_string().contains("bad-format"));
967 assert!(err.to_string().contains("word-word-word-word"));
968 }
969
970 #[test]
975 fn test_invite_for_all_entity_types() {
976 let entity_types = [
977 EntityType::Group,
978 EntityType::Channel,
979 EntityType::Project,
980 EntityType::Organisation,
981 EntityType::Person,
982 ];
983
984 for entity_type in entity_types {
985 let invite = Invite::new(
986 "creator-a-b-c".to_string(),
987 "recipient-d-e-f".to_string(),
988 "entity-123".to_string(),
989 entity_type,
990 "member".to_string(),
991 None,
992 None,
993 );
994
995 assert_eq!(invite.entity_type, entity_type);
996 assert!(invite.is_valid());
997 }
998 }
999}
1000
1001#[cfg(test)]
1002mod proptests {
1003 use super::*;
1004 use proptest::prelude::*;
1005
1006 fn four_word_identity() -> impl Strategy<Value = String> {
1008 proptest::collection::vec("[a-z]{3,8}", 4).prop_map(|words| words.join("-"))
1010 }
1011
1012 fn role_strategy() -> impl Strategy<Value = String> {
1014 prop_oneof![
1015 Just("owner".to_string()),
1016 Just("admin".to_string()),
1017 Just("member".to_string()),
1018 Just("viewer".to_string()),
1019 Just("guest".to_string()),
1020 ]
1021 }
1022
1023 fn entity_type_strategy() -> impl Strategy<Value = EntityType> {
1025 prop_oneof![
1026 Just(EntityType::Group),
1027 Just(EntityType::Channel),
1028 Just(EntityType::Project),
1029 Just(EntityType::Organisation),
1030 Just(EntityType::Person),
1031 ]
1032 }
1033
1034 fn optional_message() -> impl Strategy<Value = Option<String>> {
1036 prop_oneof![Just(None), "[a-zA-Z0-9 ]{0,100}".prop_map(Some),]
1037 }
1038
1039 fn optional_expiry() -> impl Strategy<Value = Option<u32>> {
1041 prop_oneof![Just(None), (1u32..168).prop_map(Some),]
1042 }
1043
1044 proptest! {
1045 #[test]
1047 fn prop_new_invite_is_pending_and_valid(
1048 creator in four_word_identity(),
1049 recipient in four_word_identity(),
1050 entity_id in "[a-z0-9-]{5,20}",
1051 entity_type in entity_type_strategy(),
1052 role in role_strategy(),
1053 message in optional_message(),
1054 expires in optional_expiry(),
1055 ) {
1056 let invite = Invite::new(
1057 creator,
1058 recipient,
1059 entity_id,
1060 entity_type,
1061 role,
1062 message,
1063 expires,
1064 );
1065
1066 prop_assert!(invite.is_pending());
1067 prop_assert!(invite.is_valid());
1068 prop_assert!(!invite.is_resolved());
1069 }
1070
1071 #[test]
1073 fn prop_accept_by_recipient_succeeds(
1074 creator in four_word_identity(),
1075 recipient in four_word_identity(),
1076 entity_id in "[a-z0-9-]{5,20}",
1077 ) {
1078 let mut invite = Invite::new(
1079 creator,
1080 recipient.clone(),
1081 entity_id,
1082 EntityType::Group,
1083 "member".to_string(),
1084 None,
1085 None,
1086 );
1087
1088 let result = invite.accept(&recipient);
1089 prop_assert!(result.is_ok());
1090 prop_assert!(invite.is_accepted());
1091 prop_assert!(invite.is_resolved());
1092 }
1093
1094 #[test]
1096 fn prop_accept_by_non_recipient_fails(
1097 creator in four_word_identity(),
1098 recipient in four_word_identity(),
1099 wrong_person in four_word_identity(),
1100 entity_id in "[a-z0-9-]{5,20}",
1101 ) {
1102 prop_assume!(recipient != wrong_person);
1103
1104 let mut invite = Invite::new(
1105 creator,
1106 recipient,
1107 entity_id,
1108 EntityType::Group,
1109 "member".to_string(),
1110 None,
1111 None,
1112 );
1113
1114 let result = invite.accept(&wrong_person);
1115 prop_assert!(result.is_err());
1116 prop_assert!(invite.is_pending());
1117 }
1118
1119 #[test]
1121 fn prop_resolved_invite_cannot_be_changed(
1122 creator in four_word_identity(),
1123 recipient in four_word_identity(),
1124 entity_id in "[a-z0-9-]{5,20}",
1125 action in prop_oneof![Just("accept"), Just("reject"), Just("revoke")],
1126 ) {
1127 let mut invite = Invite::new(
1128 creator.clone(),
1129 recipient.clone(),
1130 entity_id,
1131 EntityType::Group,
1132 "member".to_string(),
1133 None,
1134 None,
1135 );
1136
1137 match action {
1139 "accept" => { invite.accept(&recipient).unwrap(); }
1140 "reject" => { invite.reject(&recipient).unwrap(); }
1141 "revoke" => { invite.revoke(&creator).unwrap(); }
1142 _ => unreachable!(),
1143 }
1144
1145 let result1 = invite.accept(&recipient);
1147 let result2 = invite.reject(&recipient);
1148 let result3 = invite.revoke(&creator);
1149
1150 prop_assert!(result1.is_err());
1151 prop_assert!(result2.is_err());
1152 prop_assert!(result3.is_err());
1153 }
1154
1155 #[test]
1157 fn prop_expired_invite_invalid(
1158 creator in four_word_identity(),
1159 recipient in four_word_identity(),
1160 entity_id in "[a-z0-9-]{5,20}",
1161 hours in 1u32..100,
1162 ) {
1163 let invite = Invite::new(
1164 creator,
1165 recipient,
1166 entity_id,
1167 EntityType::Project,
1168 "member".to_string(),
1169 None,
1170 Some(hours),
1171 );
1172
1173 let at_creation = invite.created_at_datetime();
1175 prop_assert!(invite.is_valid_at(at_creation));
1176
1177 let after_expiry = at_creation + Duration::hours(i64::from(hours) + 1);
1179 prop_assert!(!invite.is_valid_at(after_expiry));
1180 }
1181
1182 #[test]
1184 fn prop_no_expiry_never_expires(
1185 creator in four_word_identity(),
1186 recipient in four_word_identity(),
1187 entity_id in "[a-z0-9-]{5,20}",
1188 years_in_future in 1i64..100,
1189 ) {
1190 let invite = Invite::new(
1191 creator,
1192 recipient,
1193 entity_id,
1194 EntityType::Channel,
1195 "viewer".to_string(),
1196 None,
1197 None, );
1199
1200 let future = invite.created_at_datetime() + Duration::days(years_in_future * 365);
1202 prop_assert!(!invite.is_expired_at(future));
1203 prop_assert!(invite.is_valid_at(future));
1204 }
1205
1206 #[test]
1208 fn prop_serialization_roundtrip(
1209 creator in four_word_identity(),
1210 recipient in four_word_identity(),
1211 entity_id in "[a-z0-9-]{5,20}",
1212 entity_type in entity_type_strategy(),
1213 role in role_strategy(),
1214 message in optional_message(),
1215 expires in optional_expiry(),
1216 ) {
1217 let invite = Invite::new(
1218 creator,
1219 recipient,
1220 entity_id,
1221 entity_type,
1222 role,
1223 message,
1224 expires,
1225 );
1226
1227 let json = serde_json::to_string(&invite).unwrap();
1228 let parsed: Invite = serde_json::from_str(&json).unwrap();
1229
1230 prop_assert_eq!(invite.id, parsed.id);
1231 prop_assert_eq!(invite.creator_id, parsed.creator_id);
1232 prop_assert_eq!(invite.recipient_id, parsed.recipient_id);
1233 prop_assert_eq!(invite.entity_id, parsed.entity_id);
1234 prop_assert_eq!(invite.entity_type, parsed.entity_type);
1235 prop_assert_eq!(invite.role, parsed.role);
1236 prop_assert_eq!(invite.message, parsed.message);
1237 prop_assert_eq!(invite.status, parsed.status);
1238 prop_assert_eq!(invite.created_at, parsed.created_at);
1239 prop_assert_eq!(invite.expires_at, parsed.expires_at);
1240 }
1241
1242 #[test]
1244 fn prop_status_string_roundtrip(
1245 status_idx in 0usize..5,
1246 ) {
1247 let status = InviteStatus::all()[status_idx];
1248 let s = status.to_string();
1249 let parsed: InviteStatus = s.parse().unwrap();
1250 prop_assert_eq!(status, parsed);
1251 }
1252
1253 #[test]
1255 fn prop_unique_ids(
1256 count in 2usize..10,
1257 creator in four_word_identity(),
1258 recipient in four_word_identity(),
1259 ) {
1260 let invites: Vec<Invite> = (0..count)
1261 .map(|i| Invite::new(
1262 creator.clone(),
1263 recipient.clone(),
1264 format!("entity-{}", i),
1265 EntityType::Group,
1266 "member".to_string(),
1267 None,
1268 None,
1269 ))
1270 .collect();
1271
1272 let ids: std::collections::HashSet<_> = invites.iter().map(|i| &i.id).collect();
1273 prop_assert_eq!(ids.len(), count);
1274 }
1275
1276 #[test]
1278 fn prop_resolved_by_tracks_resolver(
1279 creator in four_word_identity(),
1280 recipient in four_word_identity(),
1281 entity_id in "[a-z0-9-]{5,20}",
1282 ) {
1283 let mut invite1 = Invite::new(
1285 creator.clone(),
1286 recipient.clone(),
1287 entity_id.clone(),
1288 EntityType::Group,
1289 "member".to_string(),
1290 None,
1291 None,
1292 );
1293 invite1.accept(&recipient).unwrap();
1294 prop_assert_eq!(invite1.resolved_by, Some(recipient.clone()));
1295
1296 let mut invite2 = Invite::new(
1298 creator.clone(),
1299 recipient.clone(),
1300 entity_id.clone(),
1301 EntityType::Group,
1302 "member".to_string(),
1303 None,
1304 None,
1305 );
1306 invite2.reject(&recipient).unwrap();
1307 prop_assert_eq!(invite2.resolved_by, Some(recipient.clone()));
1308
1309 let mut invite3 = Invite::new(
1311 creator.clone(),
1312 recipient,
1313 entity_id,
1314 EntityType::Group,
1315 "member".to_string(),
1316 None,
1317 None,
1318 );
1319 invite3.revoke(&creator).unwrap();
1320 prop_assert_eq!(invite3.resolved_by, Some(creator));
1321 }
1322 }
1323}