Skip to main content

communitas_core/
invite.rs

1//! Invite model for cross-organization collaboration.
2//!
3//! This module defines the invite structure and status tracking for
4//! inviting external collaborators to entities via four-word identities.
5
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use crate::crdt::EntityType;
11
12/// Status of an invite throughout its lifecycle.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InviteStatus {
16    /// Invite is awaiting recipient action.
17    Pending,
18    /// Invite was accepted by recipient.
19    Accepted,
20    /// Invite was rejected by recipient.
21    Rejected,
22    /// Invite expired before any action.
23    Expired,
24    /// Invite was revoked by creator or admin.
25    Revoked,
26}
27
28impl InviteStatus {
29    /// Check if this status represents a terminal state.
30    pub fn is_terminal(self) -> bool {
31        !matches!(self, InviteStatus::Pending)
32    }
33
34    /// Get all possible status values.
35    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/// Errors when parsing invite data.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum InviteParseError {
76    /// Invalid status string.
77    InvalidStatus(String),
78    /// Invalid four-word identity format.
79    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/// An invitation to join an entity.
106///
107/// Invites are created by entity members with appropriate permissions
108/// and target a specific four-word identity. On acceptance, the recipient
109/// becomes a member with the specified role.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Invite {
112    /// Unique identifier for this invite.
113    pub id: String,
114
115    /// Four-word identity of the invite creator.
116    pub creator_id: String,
117
118    /// Four-word identity of the intended recipient.
119    pub recipient_id: String,
120
121    /// Target entity ID.
122    pub entity_id: String,
123
124    /// Target entity type.
125    pub entity_type: EntityType,
126
127    /// Role to grant on acceptance.
128    pub role: String,
129
130    /// Optional message from creator to recipient.
131    pub message: Option<String>,
132
133    /// Current status of the invite.
134    pub status: InviteStatus,
135
136    /// When the invite was created (Unix timestamp ms).
137    pub created_at: i64,
138
139    /// When the invite expires, if set (Unix timestamp ms).
140    pub expires_at: Option<i64>,
141
142    /// When the invite was resolved (accepted/rejected/revoked).
143    pub resolved_at: Option<i64>,
144
145    /// Who resolved the invite (recipient for accept/reject, admin for revoke).
146    pub resolved_by: Option<String>,
147}
148
149impl Invite {
150    /// Create a new pending invite.
151    ///
152    /// # Arguments
153    ///
154    /// * `creator_id` - Four-word identity of the creator
155    /// * `recipient_id` - Four-word identity of the recipient
156    /// * `entity_id` - ID of the entity to join
157    /// * `entity_type` - Type of the entity
158    /// * `role` - Role to grant on acceptance
159    /// * `message` - Optional message to recipient
160    /// * `expires_in_hours` - Optional expiration in hours from now
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// use communitas_core::invite::Invite;
166    /// use communitas_core::crdt::EntityType;
167    ///
168    /// let invite = Invite::new(
169    ///     "alice-brave-cloud-dawn".to_string(),
170    ///     "bob-calm-river-east".to_string(),
171    ///     "project-123".to_string(),
172    ///     EntityType::Project,
173    ///     "member".to_string(),
174    ///     Some("Join our project!".to_string()),
175    ///     Some(168), // 1 week
176    /// );
177    ///
178    /// assert!(invite.is_pending());
179    /// assert!(invite.is_valid());
180    /// ```
181    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    /// Create an invite with a specific ID (for deserialization/testing).
212    ///
213    /// This is primarily used for testing and deserialization where the ID
214    /// is already known. For normal invite creation, use `Invite::new()`.
215    #[cfg(test)]
216    pub fn with_id(id: String, mut base: Self) -> Self {
217        base.id = id;
218        base
219    }
220
221    /// Set a custom ID on this invite (builder pattern).
222    ///
223    /// Returns self for method chaining.
224    pub fn set_id(mut self, id: String) -> Self {
225        self.id = id;
226        self
227    }
228
229    /// Check if the invite is still pending.
230    pub fn is_pending(&self) -> bool {
231        self.status == InviteStatus::Pending
232    }
233
234    /// Check if the invite has been accepted.
235    pub fn is_accepted(&self) -> bool {
236        self.status == InviteStatus::Accepted
237    }
238
239    /// Check if the invite is in a terminal state.
240    pub fn is_resolved(&self) -> bool {
241        self.status.is_terminal()
242    }
243
244    /// Check if the invite has expired based on current time.
245    pub fn is_expired(&self) -> bool {
246        self.is_expired_at(Utc::now())
247    }
248
249    /// Check if the invite would be expired at a given time.
250    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    /// Check if the invite is valid (pending and not expired).
259    pub fn is_valid(&self) -> bool {
260        self.is_valid_at(Utc::now())
261    }
262
263    /// Check if the invite would be valid at a given time.
264    pub fn is_valid_at(&self, at: DateTime<Utc>) -> bool {
265        self.is_pending() && !self.is_expired_at(at)
266    }
267
268    /// Accept the invite.
269    ///
270    /// Returns `Err` if invite is not in a valid state.
271    pub fn accept(&mut self, acceptor_id: &str) -> Result<(), InviteActionError> {
272        self.accept_at(acceptor_id, Utc::now())
273    }
274
275    /// Accept the invite at a specific time (for testing).
276    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    /// Reject the invite.
303    ///
304    /// Returns `Err` if invite is not in a valid state.
305    pub fn reject(&mut self, rejector_id: &str) -> Result<(), InviteActionError> {
306        self.reject_at(rejector_id, Utc::now())
307    }
308
309    /// Reject the invite at a specific time (for testing).
310    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        // Note: Can reject even if expired (just marking intent)
327        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    /// Revoke the invite (creator or admin action).
334    ///
335    /// Returns `Err` if invite is not pending.
336    pub fn revoke(&mut self, revoker_id: &str) -> Result<(), InviteActionError> {
337        self.revoke_at(revoker_id, Utc::now())
338    }
339
340    /// Revoke the invite at a specific time (for testing).
341    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    /// Mark the invite as expired.
357    ///
358    /// This is typically called during cleanup when checking validity.
359    pub fn mark_expired(&mut self) -> Result<(), InviteActionError> {
360        self.mark_expired_at(Utc::now())
361    }
362
363    /// Mark the invite as expired at a specific time.
364    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    /// Get the created_at timestamp as DateTime.
375    pub fn created_at_datetime(&self) -> DateTime<Utc> {
376        DateTime::from_timestamp_millis(self.created_at).unwrap_or_else(Utc::now)
377    }
378
379    /// Get the expires_at timestamp as DateTime, if set.
380    pub fn expires_at_datetime(&self) -> Option<DateTime<Utc>> {
381        self.expires_at.and_then(DateTime::from_timestamp_millis)
382    }
383
384    /// Get the resolved_at timestamp as DateTime, if set.
385    pub fn resolved_at_datetime(&self) -> Option<DateTime<Utc>> {
386        self.resolved_at.and_then(DateTime::from_timestamp_millis)
387    }
388
389    /// Get time remaining until expiration, if applicable.
390    pub fn time_remaining(&self) -> Option<Duration> {
391        self.time_remaining_at(Utc::now())
392    }
393
394    /// Get time remaining until expiration at a specific time.
395    pub fn time_remaining_at(&self, at: DateTime<Utc>) -> Option<Duration> {
396        self.expires_at_datetime().map(|expires| expires - at)
397    }
398}
399
400/// Errors when performing actions on an invite.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum InviteActionError {
403    /// The invite has already been resolved (accepted, rejected, etc.).
404    AlreadyResolved(InviteStatus),
405    /// The invite has expired.
406    Expired,
407    /// The actor is not the intended recipient.
408    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    // Test helper to create a basic invite
436    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    // ============================================
461    // Invite Creation Tests
462    // ============================================
463
464    #[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    // ============================================
558    // Expiration Tests
559    // ============================================
560
561    #[test]
562    fn test_is_expired_before_expiry_time() {
563        let invite = create_test_invite_with_expiry(24);
564
565        // Check at creation time - should not be expired
566        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); // 1 hour
573
574        // Check 2 hours after creation - should be expired
575        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        // Check exactly at expiry - should not be expired (> not >=)
584        let at_expiry = invite.expires_at_datetime().unwrap();
585        assert!(!invite.is_expired_at(at_expiry));
586
587        // 1ms after should be expired
588        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        // At creation: pending and not expired = valid
597        let at_creation = invite.created_at_datetime();
598        assert!(invite.is_valid_at(at_creation));
599
600        // After expiry: pending but expired = not valid
601        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        // Should be approximately 24 hours (allow 1 second tolerance)
612        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    // ============================================
622    // Accept Tests
623    // ============================================
624
625    #[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        // Invite should still be pending
654        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        // Invite should still be pending (not auto-marked expired)
690        assert!(invite.is_pending());
691    }
692
693    // ============================================
694    // Reject Tests
695    // ============================================
696
697    #[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        // You can reject an expired invite (just recording the rejection)
730        let mut invite = create_test_invite_with_expiry(1);
731        let after_expiry = invite.created_at_datetime() + Duration::hours(2);
732
733        // This should succeed - rejection just marks intent
734        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    // ============================================
740    // Revoke Tests
741    // ============================================
742
743    #[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        // Admin (not creator) can also revoke
762        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    // ============================================
777    // Mark Expired Tests
778    // ============================================
779
780    #[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    // ============================================
800    // InviteStatus Tests
801    // ============================================
802
803    #[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    // ============================================
859    // Serialization Tests
860    // ============================================
861
862    #[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    // ============================================
900    // DateTime Helper Tests
901    // ============================================
902
903    #[test]
904    fn test_created_at_datetime() {
905        let invite = create_test_invite();
906        let dt = invite.created_at_datetime();
907
908        // Should be very close to now
909        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    // ============================================
939    // Error Display Tests
940    // ============================================
941
942    #[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    // ============================================
971    // Entity Type Tests
972    // ============================================
973
974    #[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    // Strategy for generating valid four-word identities
1007    fn four_word_identity() -> impl Strategy<Value = String> {
1008        // Generate 4 words of 3-8 lowercase letters each
1009        proptest::collection::vec("[a-z]{3,8}", 4).prop_map(|words| words.join("-"))
1010    }
1011
1012    // Strategy for generating roles
1013    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    // Strategy for generating entity types
1024    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    // Strategy for optional message
1035    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    // Strategy for optional expiry (0-168 hours)
1040    fn optional_expiry() -> impl Strategy<Value = Option<u32>> {
1041        prop_oneof![Just(None), (1u32..168).prop_map(Some),]
1042    }
1043
1044    proptest! {
1045        /// Property: New invites are always pending and valid.
1046        #[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        /// Property: Accepting an invite by recipient always succeeds for valid invite.
1072        #[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        /// Property: Accepting by non-recipient always fails.
1095        #[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        /// Property: Cannot accept/reject/revoke an already resolved invite.
1120        #[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            // First action - should succeed
1138            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            // Second action - should fail
1146            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        /// Property: Invite with expiry becomes invalid after expiry time.
1156        #[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            // At creation - valid
1174            let at_creation = invite.created_at_datetime();
1175            prop_assert!(invite.is_valid_at(at_creation));
1176
1177            // After expiry - invalid
1178            let after_expiry = at_creation + Duration::hours(i64::from(hours) + 1);
1179            prop_assert!(!invite.is_valid_at(after_expiry));
1180        }
1181
1182        /// Property: Invite without expiry never expires.
1183        #[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, // No expiry
1198            );
1199
1200            // Far in the future - still valid
1201            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        /// Property: Serialization roundtrip preserves all fields.
1207        #[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        /// Property: Status roundtrip through string always works.
1243        #[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        /// Property: Each invite gets a unique ID.
1254        #[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        /// Property: resolved_by is set correctly after resolution.
1277        #[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            // Test accept
1284            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            // Test reject
1297            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            // Test revoke
1310            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}