Skip to main content

communitas_core/
invite_service.rs

1//! Invite service for managing collaboration invites.
2//!
3//! This service handles the creation, acceptance, rejection, and revocation
4//! of invites to join entities. It integrates with the permission system
5//! to enforce access control and stores invites in CRDT documents for sync.
6
7use std::collections::HashMap;
8use std::sync::{Arc, RwLock};
9
10use crate::crdt_manager::CrdtManager;
11use crate::entity_service::EntityService;
12use crate::invite::{Invite, InviteActionError, InviteStatus};
13use crate::legacy_crdt::EntityType;
14use crate::permissions::{MemberPermissions, ResourceType};
15
16/// Request data for creating an invite.
17///
18/// This struct reduces the number of parameters to `create_invite`.
19#[derive(Debug, Clone)]
20pub struct InviteRequest {
21    /// Four-word identity of the intended recipient.
22    pub recipient_id: String,
23    /// Type of entity to join.
24    pub entity_type: EntityType,
25    /// ID of the entity to join.
26    pub entity_id: String,
27    /// Role to grant on acceptance.
28    pub role: String,
29    /// Optional message to recipient.
30    pub message: Option<String>,
31    /// Optional expiration in hours.
32    pub expires_in_hours: Option<u32>,
33}
34
35impl InviteRequest {
36    /// Create a new invite request.
37    pub fn new(
38        recipient_id: impl Into<String>,
39        entity_type: EntityType,
40        entity_id: impl Into<String>,
41        role: impl Into<String>,
42    ) -> Self {
43        Self {
44            recipient_id: recipient_id.into(),
45            entity_type,
46            entity_id: entity_id.into(),
47            role: role.into(),
48            message: None,
49            expires_in_hours: None,
50        }
51    }
52
53    /// Set an optional message for the invite.
54    pub fn with_message(mut self, message: impl Into<String>) -> Self {
55        self.message = Some(message.into());
56        self
57    }
58
59    /// Set expiration time in hours.
60    pub fn with_expiration(mut self, hours: u32) -> Self {
61        self.expires_in_hours = Some(hours);
62        self
63    }
64}
65
66/// Errors from invite service operations.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum InviteServiceError {
69    /// Entity not found.
70    EntityNotFound(String),
71
72    /// Member not found in entity.
73    MemberNotFound {
74        entity_id: String,
75        member_id: String,
76    },
77
78    /// Invite not found.
79    InviteNotFound(String),
80
81    /// Permission denied for this operation.
82    PermissionDenied(String),
83
84    /// Invalid four-word identity format.
85    InvalidFourWords(String),
86
87    /// Invite has expired.
88    InviteExpired,
89
90    /// Invite already resolved (accepted, rejected, etc.).
91    AlreadyResolved(InviteStatus),
92
93    /// Cannot grant a role higher than your own.
94    RoleEscalation {
95        granter_role: String,
96        target_role: String,
97    },
98
99    /// Recipient is already a member.
100    AlreadyMember {
101        entity_id: String,
102        member_id: String,
103    },
104
105    /// CRDT operation failed.
106    CrdtError(String),
107
108    /// Entity service error.
109    EntityServiceError(String),
110
111    /// Invite action failed.
112    InviteActionFailed(String),
113}
114
115impl std::fmt::Display for InviteServiceError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::EntityNotFound(id) => write!(f, "entity not found: {}", id),
119            Self::MemberNotFound {
120                entity_id,
121                member_id,
122            } => {
123                write!(
124                    f,
125                    "member '{}' not found in entity '{}'",
126                    member_id, entity_id
127                )
128            }
129            Self::InviteNotFound(id) => write!(f, "invite not found: {}", id),
130            Self::PermissionDenied(msg) => write!(f, "permission denied: {}", msg),
131            Self::InvalidFourWords(id) => write!(f, "invalid four-word identity: {}", id),
132            Self::InviteExpired => write!(f, "invite has expired"),
133            Self::AlreadyResolved(status) => {
134                write!(f, "invite already resolved with status: {}", status)
135            }
136            Self::RoleEscalation {
137                granter_role,
138                target_role,
139            } => {
140                write!(
141                    f,
142                    "cannot grant '{}' role when you have '{}' role",
143                    target_role, granter_role
144                )
145            }
146            Self::AlreadyMember {
147                entity_id,
148                member_id,
149            } => {
150                write!(f, "'{}' is already a member of '{}'", member_id, entity_id)
151            }
152            Self::CrdtError(msg) => write!(f, "CRDT error: {}", msg),
153            Self::EntityServiceError(msg) => write!(f, "entity service error: {}", msg),
154            Self::InviteActionFailed(msg) => write!(f, "invite action failed: {}", msg),
155        }
156    }
157}
158
159impl std::error::Error for InviteServiceError {}
160
161impl From<InviteActionError> for InviteServiceError {
162    fn from(err: InviteActionError) -> Self {
163        match err {
164            InviteActionError::AlreadyResolved(status) => Self::AlreadyResolved(status),
165            InviteActionError::Expired => Self::InviteExpired,
166            InviteActionError::NotRecipient { expected, actual } => {
167                Self::PermissionDenied(format!(
168                    "not the invite recipient: expected '{}', got '{}'",
169                    expected, actual
170                ))
171            }
172        }
173    }
174}
175
176/// Result type for invite service operations.
177pub type InviteServiceResult<T> = Result<T, InviteServiceError>;
178
179/// Role hierarchy for permission checking.
180///
181/// Higher values indicate more privileges.
182fn role_rank(role: &str) -> u8 {
183    match role.to_lowercase().as_str() {
184        "owner" => 5,
185        "admin" => 4,
186        "member" => 3,
187        "viewer" => 2,
188        "guest" => 1,
189        _ => 0,
190    }
191}
192
193/// Check if a granter can grant a target role.
194///
195/// Returns true if the granter's role is >= target role.
196pub fn can_grant_role(granter_role: &str, target_role: &str) -> bool {
197    role_rank(granter_role) >= role_rank(target_role)
198}
199
200/// Validate a four-word identity format.
201///
202/// Checks that the identity has exactly 4 words separated by dashes.
203/// Does not validate against the dictionary (that's done elsewhere).
204pub fn validate_four_words_format(identity: &str) -> bool {
205    let parts: Vec<&str> = identity.split('-').collect();
206    parts.len() == 4
207        && parts
208            .iter()
209            .all(|w| !w.is_empty() && w.chars().all(|c| c.is_alphabetic()))
210}
211
212/// Service for managing collaboration invites.
213///
214/// Invites allow entity members to invite external collaborators
215/// via their four-word identity. The service enforces:
216/// - Permission checks (must have Edit on Members)
217/// - Role hierarchy (can't grant higher role than own)
218/// - CRDT persistence for offline-first sync
219pub struct InviteService {
220    /// CRDT manager for storing invites.
221    crdt_manager: Arc<CrdtManager>,
222
223    /// Entity service for member operations.
224    entity_service: Arc<EntityService>,
225
226    /// In-memory invite cache (indexed by invite ID).
227    /// This is used for quick lookups before falling back to CRDT.
228    invite_cache: RwLock<HashMap<String, Invite>>,
229
230    /// Index of invites by recipient (for list_pending_invites).
231    recipient_index: RwLock<HashMap<String, Vec<String>>>,
232
233    /// Index of invites by entity (for list_entity_invites).
234    entity_index: RwLock<HashMap<String, Vec<String>>>,
235}
236
237impl InviteService {
238    /// Create a new invite service.
239    pub fn new(crdt_manager: Arc<CrdtManager>, entity_service: Arc<EntityService>) -> Self {
240        Self {
241            crdt_manager,
242            entity_service,
243            invite_cache: RwLock::new(HashMap::new()),
244            recipient_index: RwLock::new(HashMap::new()),
245            entity_index: RwLock::new(HashMap::new()),
246        }
247    }
248
249    /// Create an invite to join an entity.
250    ///
251    /// # Arguments
252    ///
253    /// * `creator_id` - Four-word identity of the invite creator
254    /// * `request` - The invite request containing recipient, entity, and role details
255    ///
256    /// # Errors
257    ///
258    /// * `PermissionDenied` - Creator doesn't have Edit on Members
259    /// * `RoleEscalation` - Trying to grant higher role than own
260    /// * `InvalidFourWords` - Invalid recipient identity format
261    /// * `AlreadyMember` - Recipient is already a member
262    pub async fn create_invite(
263        &self,
264        creator_id: &str,
265        request: InviteRequest,
266    ) -> InviteServiceResult<Invite> {
267        // 1. Validate recipient format
268        if !validate_four_words_format(&request.recipient_id) {
269            return Err(InviteServiceError::InvalidFourWords(
270                request.recipient_id.clone(),
271            ));
272        }
273
274        // 2. Get creator's permissions
275        let creator_perms = self
276            .get_member_permissions(request.entity_type, &request.entity_id, creator_id)
277            .await?;
278
279        // 3. Check creator has Edit on Members
280        if !creator_perms.can_edit(ResourceType::Members) {
281            return Err(InviteServiceError::PermissionDenied(
282                "must have Edit access to Members to create invites".to_string(),
283            ));
284        }
285
286        // 4. Check role hierarchy
287        if !can_grant_role(&creator_perms.role, &request.role) {
288            return Err(InviteServiceError::RoleEscalation {
289                granter_role: creator_perms.role.clone(),
290                target_role: request.role.clone(),
291            });
292        }
293
294        // 5. Check recipient is not already a member
295        if self
296            .is_member(
297                request.entity_type,
298                &request.entity_id,
299                &request.recipient_id,
300            )
301            .await
302        {
303            return Err(InviteServiceError::AlreadyMember {
304                entity_id: request.entity_id.clone(),
305                member_id: request.recipient_id.clone(),
306            });
307        }
308
309        // 6. Create the invite
310        let invite = Invite::new(
311            creator_id.to_string(),
312            request.recipient_id,
313            request.entity_id,
314            request.entity_type,
315            request.role,
316            request.message,
317            request.expires_in_hours,
318        );
319
320        // 7. Store in CRDT and cache
321        self.store_invite(&invite).await?;
322
323        Ok(invite)
324    }
325
326    /// Accept a pending invite.
327    ///
328    /// This adds the recipient as a member with the specified role.
329    ///
330    /// # Arguments
331    ///
332    /// * `recipient_id` - Four-word identity of the recipient accepting
333    /// * `invite_id` - ID of the invite to accept
334    ///
335    /// # Errors
336    ///
337    /// * `InviteNotFound` - Invite doesn't exist
338    /// * `PermissionDenied` - Actor is not the recipient
339    /// * `InviteExpired` - Invite has expired
340    /// * `AlreadyResolved` - Invite already accepted/rejected/revoked
341    pub async fn accept_invite(
342        &self,
343        recipient_id: &str,
344        invite_id: &str,
345    ) -> InviteServiceResult<()> {
346        // 1. Get the invite
347        let mut invite = self.get_invite(invite_id).await?;
348
349        // 2. Accept (validates recipient and state)
350        invite.accept(recipient_id)?;
351
352        // 3. Add recipient as member
353        self.entity_service
354            .add_member(
355                invite.entity_type,
356                &invite.entity_id,
357                recipient_id,
358                &invite.role,
359            )
360            .await
361            .map_err(|e| InviteServiceError::EntityServiceError(e.to_string()))?;
362
363        // 4. Update invite in storage
364        self.update_invite(&invite).await?;
365
366        Ok(())
367    }
368
369    /// Reject a pending invite.
370    ///
371    /// # Arguments
372    ///
373    /// * `recipient_id` - Four-word identity of the recipient rejecting
374    /// * `invite_id` - ID of the invite to reject
375    ///
376    /// # Errors
377    ///
378    /// * `InviteNotFound` - Invite doesn't exist
379    /// * `PermissionDenied` - Actor is not the recipient
380    /// * `AlreadyResolved` - Invite already accepted/rejected/revoked
381    pub async fn reject_invite(
382        &self,
383        recipient_id: &str,
384        invite_id: &str,
385    ) -> InviteServiceResult<()> {
386        let mut invite = self.get_invite(invite_id).await?;
387        invite.reject(recipient_id)?;
388        self.update_invite(&invite).await?;
389        Ok(())
390    }
391
392    /// Revoke a pending invite.
393    ///
394    /// Can be done by the creator or anyone with Edit on Members.
395    ///
396    /// # Arguments
397    ///
398    /// * `revoker_id` - Four-word identity of the person revoking
399    /// * `invite_id` - ID of the invite to revoke
400    ///
401    /// # Errors
402    ///
403    /// * `InviteNotFound` - Invite doesn't exist
404    /// * `PermissionDenied` - Actor is not creator and lacks permissions
405    /// * `AlreadyResolved` - Invite already resolved
406    pub async fn revoke_invite(
407        &self,
408        revoker_id: &str,
409        invite_id: &str,
410    ) -> InviteServiceResult<()> {
411        let mut invite = self.get_invite(invite_id).await?;
412
413        // Check authorization: must be creator OR have Edit on Members
414        if invite.creator_id != revoker_id {
415            let perms = self
416                .get_member_permissions(invite.entity_type, &invite.entity_id, revoker_id)
417                .await?;
418
419            if !perms.can_edit(ResourceType::Members) {
420                return Err(InviteServiceError::PermissionDenied(
421                    "only creator or admin can revoke invites".to_string(),
422                ));
423            }
424        }
425
426        invite.revoke(revoker_id)?;
427        self.update_invite(&invite).await?;
428        Ok(())
429    }
430
431    /// List pending invites for a recipient.
432    ///
433    /// Returns all invites where the recipient matches and status is Pending.
434    pub async fn list_pending_invites(
435        &self,
436        recipient_id: &str,
437    ) -> InviteServiceResult<Vec<Invite>> {
438        self.list_invites_for_recipient(recipient_id, Some(InviteStatus::Pending))
439            .await
440    }
441
442    /// List all invites for a recipient (including resolved).
443    pub async fn list_all_invites_for_recipient(
444        &self,
445        recipient_id: &str,
446    ) -> InviteServiceResult<Vec<Invite>> {
447        self.list_invites_for_recipient(recipient_id, None).await
448    }
449
450    /// List invites for an entity (admin view).
451    ///
452    /// Requires at least ReadOnly on Members to view.
453    ///
454    /// # Arguments
455    ///
456    /// * `requester_id` - Four-word identity of the person requesting
457    /// * `entity_type` - Type of entity
458    /// * `entity_id` - ID of the entity
459    ///
460    /// # Errors
461    ///
462    /// * `PermissionDenied` - Requester lacks access to Members
463    pub async fn list_entity_invites(
464        &self,
465        requester_id: &str,
466        entity_type: EntityType,
467        entity_id: &str,
468    ) -> InviteServiceResult<Vec<Invite>> {
469        // Check permissions
470        let perms = self
471            .get_member_permissions(entity_type, entity_id, requester_id)
472            .await?;
473
474        if !perms.can_view(ResourceType::Members) {
475            return Err(InviteServiceError::PermissionDenied(
476                "must have access to Members to view invites".to_string(),
477            ));
478        }
479
480        self.get_invites_for_entity(entity_type, entity_id).await
481    }
482
483    /// Get a single invite by ID.
484    pub async fn get_invite(&self, invite_id: &str) -> InviteServiceResult<Invite> {
485        // Check cache first
486        {
487            let cache = self
488                .invite_cache
489                .read()
490                .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
491            if let Some(invite) = cache.get(invite_id) {
492                return Ok(invite.clone());
493            }
494        }
495
496        // Load from CRDT
497        self.load_invite_from_crdt(invite_id).await
498    }
499
500    // ========================================
501    // Private helper methods
502    // ========================================
503
504    /// Get member permissions for an entity.
505    async fn get_member_permissions(
506        &self,
507        entity_type: EntityType,
508        entity_id: &str,
509        member_id: &str,
510    ) -> InviteServiceResult<MemberPermissions> {
511        // Get role from entity service
512        let role = self
513            .entity_service
514            .get_member_role(entity_type, entity_id, member_id)
515            .await
516            .map_err(|e| {
517                // Check if it's a "member not found" error
518                let err_str = e.to_string();
519                if err_str.contains("not found") || err_str.contains("not a member") {
520                    InviteServiceError::MemberNotFound {
521                        entity_id: entity_id.to_string(),
522                        member_id: member_id.to_string(),
523                    }
524                } else {
525                    InviteServiceError::EntityServiceError(err_str)
526                }
527            })?;
528
529        // Get permission overrides
530        let overrides = self
531            .entity_service
532            .get_permission_overrides(entity_type, entity_id, member_id)
533            .await
534            .unwrap_or_default();
535
536        // Build MemberPermissions
537        let mut perms = MemberPermissions::new(member_id.to_string(), role);
538        for (resource_str, level_str) in overrides {
539            if let (Ok(resource), Ok(level)) = (resource_str.parse(), level_str.parse()) {
540                perms.overrides.insert(resource, level);
541            }
542        }
543
544        Ok(perms)
545    }
546
547    /// Check if someone is already a member of an entity.
548    async fn is_member(&self, entity_type: EntityType, entity_id: &str, member_id: &str) -> bool {
549        self.entity_service
550            .get_member_role(entity_type, entity_id, member_id)
551            .await
552            .is_ok()
553    }
554
555    /// Store an invite in CRDT and update indices.
556    async fn store_invite(&self, invite: &Invite) -> InviteServiceResult<()> {
557        // Store in cache
558        {
559            let mut cache = self
560                .invite_cache
561                .write()
562                .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
563            cache.insert(invite.id.clone(), invite.clone());
564        }
565
566        // Update recipient index
567        {
568            let mut index = self
569                .recipient_index
570                .write()
571                .map_err(|_| InviteServiceError::CrdtError("index lock poisoned".to_string()))?;
572            index
573                .entry(invite.recipient_id.clone())
574                .or_default()
575                .push(invite.id.clone());
576        }
577
578        // Update entity index
579        {
580            let entity_key = format!("{:?}:{}", invite.entity_type, invite.entity_id);
581            let mut index = self
582                .entity_index
583                .write()
584                .map_err(|_| InviteServiceError::CrdtError("index lock poisoned".to_string()))?;
585            index.entry(entity_key).or_default().push(invite.id.clone());
586        }
587
588        // Store in CRDT
589        self.store_invite_in_crdt(invite).await
590    }
591
592    /// Update an invite in storage.
593    async fn update_invite(&self, invite: &Invite) -> InviteServiceResult<()> {
594        // Update cache
595        {
596            let mut cache = self
597                .invite_cache
598                .write()
599                .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
600            cache.insert(invite.id.clone(), invite.clone());
601        }
602
603        // Update CRDT
604        self.store_invite_in_crdt(invite).await
605    }
606
607    /// Store invite in CRDT document.
608    ///
609    /// Stores the invite in a per-entity CRDT document using the format:
610    /// `{entity_type}:{entity_id}:invites`
611    ///
612    /// Also updates the global index mapping invite_id -> entity_doc_id
613    /// for efficient lookup by invite ID alone.
614    ///
615    /// The invite is stored as a JSON blob in a YMap keyed by invite_id.
616    async fn store_invite_in_crdt(&self, invite: &Invite) -> InviteServiceResult<()> {
617        use yrs::{Doc, Map, Transact};
618
619        let doc_id = self.entity_invite_doc_id(invite.entity_type, &invite.entity_id);
620        let entity_type_str = format!("{:?}", invite.entity_type).to_lowercase();
621
622        // Load existing doc or create new one
623        let doc = match self.crdt_manager.load_document(&doc_id).await {
624            Ok(doc) => doc,
625            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => Doc::new(),
626            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
627        };
628
629        // Serialize invite to JSON
630        let invite_json = serde_json::to_string(invite).map_err(|e| {
631            InviteServiceError::CrdtError(format!("Failed to serialize invite: {}", e))
632        })?;
633
634        // Store in YMap
635        {
636            let invites_map = doc.get_or_insert_map("invites");
637            let mut txn = doc.transact_mut();
638            invites_map.insert(&mut txn, invite.id.clone(), invite_json);
639        }
640
641        // Save the entity document
642        self.crdt_manager
643            .save_document(&doc_id, &entity_type_str, &invite.entity_id, &doc)
644            .await
645            .map_err(|e| InviteServiceError::CrdtError(e.to_string()))?;
646
647        // Update global index for invite_id -> entity_doc_id lookup
648        self.update_global_invite_index(&invite.id, &doc_id).await?;
649
650        Ok(())
651    }
652
653    /// Update the global invite index with a mapping from invite_id to entity_doc_id.
654    async fn update_global_invite_index(
655        &self,
656        invite_id: &str,
657        entity_doc_id: &str,
658    ) -> InviteServiceResult<()> {
659        use yrs::{Doc, Map, Transact};
660
661        let index_doc_id = self.global_invite_index_doc_id();
662
663        // Load existing index doc or create new one
664        let index_doc = match self.crdt_manager.load_document(&index_doc_id).await {
665            Ok(doc) => doc,
666            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => Doc::new(),
667            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
668        };
669
670        // Store mapping from invite_id to entity_doc_id
671        {
672            let index_map = index_doc.get_or_insert_map("invite_to_entity");
673            let mut txn = index_doc.transact_mut();
674            index_map.insert(&mut txn, invite_id, entity_doc_id);
675        }
676
677        // Save the global index document
678        // Format: invites:global:index
679        self.crdt_manager
680            .save_document(&index_doc_id, "invites", "global", &index_doc)
681            .await
682            .map_err(|e| InviteServiceError::CrdtError(e.to_string()))?;
683
684        Ok(())
685    }
686
687    /// Load invite from CRDT.
688    ///
689    /// Loads an invite by searching for it in entity documents.
690    /// Uses a global invite index for efficient lookup.
691    async fn load_invite_from_crdt(&self, invite_id: &str) -> InviteServiceResult<Invite> {
692        use yrs::{Map, Transact};
693
694        // First check in-memory cache
695        {
696            let cache = self
697                .invite_cache
698                .read()
699                .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
700            if let Some(invite) = cache.get(invite_id) {
701                return Ok(invite.clone());
702            }
703        }
704
705        // Load from global invite index
706        let index_doc_id = self.global_invite_index_doc_id();
707        let index_doc = match self.crdt_manager.load_document(&index_doc_id).await {
708            Ok(doc) => doc,
709            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => {
710                return Err(InviteServiceError::InviteNotFound(invite_id.to_string()));
711            }
712            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
713        };
714
715        // Find the entity doc containing this invite
716        let entity_doc_id = {
717            let index_map = index_doc.get_or_insert_map("invite_to_entity");
718            let txn = index_doc.transact();
719            match index_map.get(&txn, invite_id) {
720                Some(yrs::Out::Any(yrs::Any::String(s))) => s.to_string(),
721                _ => return Err(InviteServiceError::InviteNotFound(invite_id.to_string())),
722            }
723        };
724
725        // Load the entity document
726        let entity_doc = self
727            .crdt_manager
728            .load_document(&entity_doc_id)
729            .await
730            .map_err(|e| InviteServiceError::CrdtError(e.to_string()))?;
731
732        // Get the invite from the entity document
733        let invites_map = entity_doc.get_or_insert_map("invites");
734        let txn = entity_doc.transact();
735        let invite_json = match invites_map.get(&txn, invite_id) {
736            Some(yrs::Out::Any(yrs::Any::String(s))) => s.to_string(),
737            _ => return Err(InviteServiceError::InviteNotFound(invite_id.to_string())),
738        };
739
740        let invite: Invite = serde_json::from_str(&invite_json).map_err(|e| {
741            InviteServiceError::CrdtError(format!("Failed to deserialize invite: {}", e))
742        })?;
743
744        Ok(invite)
745    }
746
747    /// Load all invites for an entity from CRDT.
748    ///
749    /// This is currently only used in tests but will be used when we integrate
750    /// CRDT-based storage with the list_entity_invites public API.
751    #[allow(dead_code)]
752    async fn load_invites_for_entity_from_crdt(
753        &self,
754        entity_type: EntityType,
755        entity_id: &str,
756    ) -> InviteServiceResult<Vec<Invite>> {
757        use yrs::{Map, Transact};
758
759        let doc_id = self.entity_invite_doc_id(entity_type, entity_id);
760
761        // Load the entity document
762        let doc = match self.crdt_manager.load_document(&doc_id).await {
763            Ok(doc) => doc,
764            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => {
765                return Ok(Vec::new()); // No invites for this entity
766            }
767            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
768        };
769
770        // Get all invites from the document
771        let invites_map = doc.get_or_insert_map("invites");
772        let txn = doc.transact();
773
774        let mut invites = Vec::new();
775        for (key, value) in invites_map.iter(&txn) {
776            if let yrs::Out::Any(yrs::Any::String(invite_json)) = value {
777                let invite: Invite = serde_json::from_str(invite_json.as_ref()).map_err(|e| {
778                    InviteServiceError::CrdtError(format!(
779                        "Failed to deserialize invite {}: {}",
780                        key, e
781                    ))
782                })?;
783                invites.push(invite);
784            }
785        }
786
787        Ok(invites)
788    }
789
790    /// Store recipient index in CRDT.
791    ///
792    /// The recipient index maps recipient IDs to lists of invite IDs.
793    /// Used to quickly look up all invites for a given recipient.
794    ///
795    /// This is currently only used in tests but will be used when we integrate
796    /// CRDT-based storage with the list_pending_invites public API.
797    #[allow(dead_code)]
798    async fn store_recipient_index_in_crdt(
799        &self,
800        recipient_id: &str,
801        invite_ids: &[String],
802    ) -> InviteServiceResult<()> {
803        use yrs::{Doc, Map, Transact};
804
805        let doc_id = self.recipient_index_doc_id(recipient_id);
806
807        // Load existing doc or create new one
808        let doc = match self.crdt_manager.load_document(&doc_id).await {
809            Ok(doc) => doc,
810            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => Doc::new(),
811            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
812        };
813
814        // Serialize invite IDs to JSON array
815        let invite_ids_json = serde_json::to_string(invite_ids).map_err(|e| {
816            InviteServiceError::CrdtError(format!("Failed to serialize invite IDs: {}", e))
817        })?;
818
819        // Store in YMap
820        {
821            let index_map = doc.get_or_insert_map("recipient_invites");
822            let mut txn = doc.transact_mut();
823            index_map.insert(&mut txn, recipient_id, invite_ids_json);
824        }
825
826        // Save the document
827        self.crdt_manager
828            .save_document(&doc_id, "invites", recipient_id, &doc)
829            .await
830            .map_err(|e| InviteServiceError::CrdtError(e.to_string()))?;
831
832        Ok(())
833    }
834
835    /// Load recipient index from CRDT.
836    ///
837    /// This is currently only used in tests but will be used when we integrate
838    /// CRDT-based storage with the list_pending_invites public API.
839    #[allow(dead_code)]
840    async fn load_recipient_index_from_crdt(
841        &self,
842        recipient_id: &str,
843    ) -> InviteServiceResult<Vec<String>> {
844        use yrs::{Map, Transact};
845
846        let doc_id = self.recipient_index_doc_id(recipient_id);
847
848        // Load the document
849        let doc = match self.crdt_manager.load_document(&doc_id).await {
850            Ok(doc) => doc,
851            Err(crate::crdt_manager::CrdtError::DocumentNotFound(_)) => {
852                return Ok(Vec::new()); // No index for this recipient
853            }
854            Err(e) => return Err(InviteServiceError::CrdtError(e.to_string())),
855        };
856
857        // Get the invite IDs
858        let index_map = doc.get_or_insert_map("recipient_invites");
859        let txn = doc.transact();
860
861        let invite_ids_json = match index_map.get(&txn, recipient_id) {
862            Some(yrs::Out::Any(yrs::Any::String(s))) => s.to_string(),
863            _ => return Ok(Vec::new()),
864        };
865
866        let invite_ids: Vec<String> = serde_json::from_str(&invite_ids_json).map_err(|e| {
867            InviteServiceError::CrdtError(format!("Failed to deserialize invite IDs: {}", e))
868        })?;
869
870        Ok(invite_ids)
871    }
872
873    /// Generate doc ID for entity invite document.
874    ///
875    /// Format: `{entity_type}:{entity_id}:invites`
876    /// e.g., `organisation:org-123:invites`, `group:grp-456:invites`
877    fn entity_invite_doc_id(&self, entity_type: EntityType, entity_id: &str) -> String {
878        let entity_type_str = format!("{:?}", entity_type).to_lowercase();
879        format!("{}:{}:invites", entity_type_str, entity_id)
880    }
881
882    /// Generate doc ID for recipient index document.
883    ///
884    /// Format: `invites:{recipient_id}:index`
885    ///
886    /// This is currently only used in tests but will be used when we integrate
887    /// CRDT-based storage with the list_pending_invites public API.
888    #[allow(dead_code)]
889    fn recipient_index_doc_id(&self, recipient_id: &str) -> String {
890        format!("invites:{}:index", recipient_id)
891    }
892
893    /// Generate doc ID for global invite index.
894    ///
895    /// Format: `invites:global:index`
896    fn global_invite_index_doc_id(&self) -> String {
897        "invites:global:index".to_string()
898    }
899
900    /// List invites for a recipient with optional status filter.
901    async fn list_invites_for_recipient(
902        &self,
903        recipient_id: &str,
904        status_filter: Option<InviteStatus>,
905    ) -> InviteServiceResult<Vec<Invite>> {
906        let invite_ids = {
907            let index = self
908                .recipient_index
909                .read()
910                .map_err(|_| InviteServiceError::CrdtError("index lock poisoned".to_string()))?;
911            index.get(recipient_id).cloned().unwrap_or_default()
912        };
913
914        let cache = self
915            .invite_cache
916            .read()
917            .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
918        let mut result = Vec::new();
919
920        for invite_id in invite_ids {
921            if let Some(invite) = cache.get(&invite_id) {
922                // Apply status filter
923                if let Some(filter) = status_filter {
924                    if invite.status == filter {
925                        // Also check if expired (for pending filter)
926                        if filter == InviteStatus::Pending && invite.is_expired() {
927                            continue;
928                        }
929                        result.push(invite.clone());
930                    }
931                } else {
932                    result.push(invite.clone());
933                }
934            }
935        }
936
937        Ok(result)
938    }
939
940    /// Get invites for an entity.
941    async fn get_invites_for_entity(
942        &self,
943        entity_type: EntityType,
944        entity_id: &str,
945    ) -> InviteServiceResult<Vec<Invite>> {
946        let entity_key = format!("{:?}:{}", entity_type, entity_id);
947        let invite_ids = {
948            let index = self
949                .entity_index
950                .read()
951                .map_err(|_| InviteServiceError::CrdtError("lock poisoned".to_string()))?;
952            index.get(&entity_key).cloned().unwrap_or_default()
953        };
954
955        let cache = self
956            .invite_cache
957            .read()
958            .map_err(|_| InviteServiceError::CrdtError("cache lock poisoned".to_string()))?;
959        let mut result = Vec::new();
960
961        for invite_id in invite_ids {
962            if let Some(invite) = cache.get(&invite_id) {
963                result.push(invite.clone());
964            }
965        }
966
967        Ok(result)
968    }
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    // Test helper: mock EntityService that tracks members
976    // Note: This is a placeholder - full integration tests will use the real EntityService
977    #[allow(dead_code)]
978    struct MockEntityService {
979        members: RwLock<HashMap<String, HashMap<String, String>>>, // entity_key -> member_id -> role
980        permission_overrides: RwLock<HashMap<String, Vec<(String, String)>>>, // member_key -> [(resource, level)]
981    }
982
983    #[allow(dead_code)]
984    impl MockEntityService {
985        fn new() -> Self {
986            Self {
987                members: RwLock::new(HashMap::new()),
988                permission_overrides: RwLock::new(HashMap::new()),
989            }
990        }
991
992        fn add_member_sync(
993            &self,
994            entity_type: EntityType,
995            entity_id: &str,
996            member_id: &str,
997            role: &str,
998        ) {
999            let entity_key = format!("{:?}:{}", entity_type, entity_id);
1000            let mut members = self.members.write().unwrap();
1001            members
1002                .entry(entity_key)
1003                .or_default()
1004                .insert(member_id.to_string(), role.to_string());
1005        }
1006
1007        fn get_role(
1008            &self,
1009            entity_type: EntityType,
1010            entity_id: &str,
1011            member_id: &str,
1012        ) -> Option<String> {
1013            let entity_key = format!("{:?}:{}", entity_type, entity_id);
1014            let members = self.members.read().unwrap();
1015            members
1016                .get(&entity_key)
1017                .and_then(|m| m.get(member_id).cloned())
1018        }
1019
1020        fn set_permission_override(
1021            &self,
1022            entity_type: EntityType,
1023            entity_id: &str,
1024            member_id: &str,
1025            resource: &str,
1026            level: &str,
1027        ) {
1028            let key = format!("{:?}:{}:{}", entity_type, entity_id, member_id);
1029            let mut overrides = self.permission_overrides.write().unwrap();
1030            overrides
1031                .entry(key)
1032                .or_default()
1033                .push((resource.to_string(), level.to_string()));
1034        }
1035
1036        fn get_overrides(
1037            &self,
1038            entity_type: EntityType,
1039            entity_id: &str,
1040            member_id: &str,
1041        ) -> Vec<(String, String)> {
1042            let key = format!("{:?}:{}:{}", entity_type, entity_id, member_id);
1043            let overrides = self.permission_overrides.read().unwrap();
1044            overrides.get(&key).cloned().unwrap_or_default()
1045        }
1046    }
1047
1048    // ============================================
1049    // Helper Function Tests
1050    // ============================================
1051
1052    #[test]
1053    fn test_role_rank() {
1054        assert_eq!(role_rank("owner"), 5);
1055        assert_eq!(role_rank("admin"), 4);
1056        assert_eq!(role_rank("member"), 3);
1057        assert_eq!(role_rank("viewer"), 2);
1058        assert_eq!(role_rank("guest"), 1);
1059        assert_eq!(role_rank("unknown"), 0);
1060    }
1061
1062    #[test]
1063    fn test_role_rank_case_insensitive() {
1064        assert_eq!(role_rank("OWNER"), 5);
1065        assert_eq!(role_rank("Owner"), 5);
1066        assert_eq!(role_rank("ADMIN"), 4);
1067    }
1068
1069    #[test]
1070    fn test_can_grant_role_same_level() {
1071        assert!(can_grant_role("member", "member"));
1072        assert!(can_grant_role("admin", "admin"));
1073        assert!(can_grant_role("owner", "owner"));
1074    }
1075
1076    #[test]
1077    fn test_can_grant_role_higher_to_lower() {
1078        assert!(can_grant_role("owner", "admin"));
1079        assert!(can_grant_role("owner", "member"));
1080        assert!(can_grant_role("owner", "viewer"));
1081        assert!(can_grant_role("owner", "guest"));
1082        assert!(can_grant_role("admin", "member"));
1083        assert!(can_grant_role("member", "viewer"));
1084    }
1085
1086    #[test]
1087    fn test_can_grant_role_lower_to_higher_fails() {
1088        assert!(!can_grant_role("guest", "viewer"));
1089        assert!(!can_grant_role("viewer", "member"));
1090        assert!(!can_grant_role("member", "admin"));
1091        assert!(!can_grant_role("admin", "owner"));
1092    }
1093
1094    #[test]
1095    fn test_validate_four_words_format_valid() {
1096        assert!(validate_four_words_format("alice-bob-carol-dave"));
1097        assert!(validate_four_words_format("ocean-forest-moon-star"));
1098        assert!(validate_four_words_format("a-b-c-d"));
1099    }
1100
1101    #[test]
1102    fn test_validate_four_words_format_invalid() {
1103        assert!(!validate_four_words_format("alice-bob-carol")); // 3 words
1104        assert!(!validate_four_words_format("alice-bob-carol-dave-eve")); // 5 words
1105        assert!(!validate_four_words_format("alice--bob-carol")); // empty word
1106        assert!(!validate_four_words_format("alice-bob123-carol-dave")); // numbers
1107        assert!(!validate_four_words_format("")); // empty
1108        assert!(!validate_four_words_format("----")); // all empty
1109    }
1110
1111    // ============================================
1112    // Error Display Tests
1113    // ============================================
1114
1115    #[test]
1116    fn test_error_display() {
1117        let err = InviteServiceError::EntityNotFound("entity-123".to_string());
1118        assert!(err.to_string().contains("entity-123"));
1119
1120        let err = InviteServiceError::MemberNotFound {
1121            entity_id: "entity-1".to_string(),
1122            member_id: "alice-a-b-c".to_string(),
1123        };
1124        assert!(err.to_string().contains("entity-1"));
1125        assert!(err.to_string().contains("alice-a-b-c"));
1126
1127        let err = InviteServiceError::RoleEscalation {
1128            granter_role: "member".to_string(),
1129            target_role: "admin".to_string(),
1130        };
1131        assert!(err.to_string().contains("member"));
1132        assert!(err.to_string().contains("admin"));
1133
1134        let err = InviteServiceError::AlreadyMember {
1135            entity_id: "entity-1".to_string(),
1136            member_id: "bob-b-c-d".to_string(),
1137        };
1138        assert!(err.to_string().contains("already a member"));
1139    }
1140
1141    #[test]
1142    fn test_error_from_invite_action_error() {
1143        let action_err = InviteActionError::Expired;
1144        let service_err: InviteServiceError = action_err.into();
1145        assert!(matches!(service_err, InviteServiceError::InviteExpired));
1146
1147        let action_err = InviteActionError::AlreadyResolved(InviteStatus::Accepted);
1148        let service_err: InviteServiceError = action_err.into();
1149        assert!(matches!(
1150            service_err,
1151            InviteServiceError::AlreadyResolved(InviteStatus::Accepted)
1152        ));
1153
1154        let action_err = InviteActionError::NotRecipient {
1155            expected: "alice-a-b-c".to_string(),
1156            actual: "bob-d-e-f".to_string(),
1157        };
1158        let service_err: InviteServiceError = action_err.into();
1159        assert!(matches!(
1160            service_err,
1161            InviteServiceError::PermissionDenied(_)
1162        ));
1163    }
1164
1165    // ============================================
1166    // InviteRequest Builder Tests
1167    // ============================================
1168
1169    #[test]
1170    fn test_invite_request_new_sets_required_fields() {
1171        let request = InviteRequest::new(
1172            "alice-bob-carol-dave",
1173            EntityType::Organisation,
1174            "org-123",
1175            "member",
1176        );
1177
1178        assert_eq!(request.recipient_id, "alice-bob-carol-dave");
1179        assert_eq!(request.entity_type, EntityType::Organisation);
1180        assert_eq!(request.entity_id, "org-123");
1181        assert_eq!(request.role, "member");
1182        assert!(request.message.is_none());
1183        assert!(request.expires_in_hours.is_none());
1184    }
1185
1186    #[test]
1187    fn test_invite_request_new_accepts_string_types() {
1188        // Test with &str
1189        let req1 = InviteRequest::new("alice-a-b-c", EntityType::Group, "group-1", "viewer");
1190        assert_eq!(req1.recipient_id, "alice-a-b-c");
1191
1192        // Test with String
1193        let req2 = InviteRequest::new(
1194            String::from("bob-d-e-f"),
1195            EntityType::Channel,
1196            String::from("channel-1"),
1197            String::from("member"),
1198        );
1199        assert_eq!(req2.recipient_id, "bob-d-e-f");
1200        assert_eq!(req2.entity_id, "channel-1");
1201        assert_eq!(req2.role, "member");
1202    }
1203
1204    #[test]
1205    fn test_invite_request_with_message() {
1206        let request = InviteRequest::new("alice-a-b-c", EntityType::Project, "proj-1", "admin")
1207            .with_message("Welcome to the team!");
1208
1209        assert_eq!(request.message, Some("Welcome to the team!".to_string()));
1210        // Other fields unchanged
1211        assert_eq!(request.recipient_id, "alice-a-b-c");
1212        assert_eq!(request.role, "admin");
1213        assert!(request.expires_in_hours.is_none());
1214    }
1215
1216    #[test]
1217    fn test_invite_request_with_expiration() {
1218        let request = InviteRequest::new("bob-x-y-z", EntityType::Organisation, "org-1", "member")
1219            .with_expiration(48);
1220
1221        assert_eq!(request.expires_in_hours, Some(48));
1222        // Other fields unchanged
1223        assert_eq!(request.recipient_id, "bob-x-y-z");
1224        assert!(request.message.is_none());
1225    }
1226
1227    #[test]
1228    fn test_invite_request_builder_chaining() {
1229        let request = InviteRequest::new("carol-m-n-o", EntityType::Group, "group-42", "viewer")
1230            .with_message("Join our group!")
1231            .with_expiration(24);
1232
1233        assert_eq!(request.recipient_id, "carol-m-n-o");
1234        assert_eq!(request.entity_type, EntityType::Group);
1235        assert_eq!(request.entity_id, "group-42");
1236        assert_eq!(request.role, "viewer");
1237        assert_eq!(request.message, Some("Join our group!".to_string()));
1238        assert_eq!(request.expires_in_hours, Some(24));
1239    }
1240
1241    #[test]
1242    fn test_invite_request_builder_order_independence() {
1243        // Expiration first, then message
1244        let req1 = InviteRequest::new("a-b-c-d", EntityType::Channel, "ch-1", "member")
1245            .with_expiration(12)
1246            .with_message("Hello");
1247
1248        // Message first, then expiration
1249        let req2 = InviteRequest::new("a-b-c-d", EntityType::Channel, "ch-1", "member")
1250            .with_message("Hello")
1251            .with_expiration(12);
1252
1253        assert_eq!(req1.message, req2.message);
1254        assert_eq!(req1.expires_in_hours, req2.expires_in_hours);
1255        assert_eq!(req1.recipient_id, req2.recipient_id);
1256        assert_eq!(req1.role, req2.role);
1257    }
1258
1259    #[test]
1260    fn test_invite_request_with_all_entity_types() {
1261        let entity_types = [
1262            EntityType::Organisation,
1263            EntityType::Group,
1264            EntityType::Channel,
1265            EntityType::Project,
1266        ];
1267
1268        for entity_type in entity_types {
1269            let request = InviteRequest::new("test-a-b-c", entity_type, "entity-id", "member");
1270            assert_eq!(request.entity_type, entity_type);
1271        }
1272    }
1273
1274    #[test]
1275    fn test_invite_request_with_all_roles() {
1276        let roles = ["owner", "admin", "member", "viewer", "guest"];
1277
1278        for role in roles {
1279            let request = InviteRequest::new("test-a-b-c", EntityType::Group, "group-1", role);
1280            assert_eq!(request.role, role);
1281        }
1282    }
1283
1284    #[test]
1285    fn test_invite_request_empty_message_allowed() {
1286        let request =
1287            InviteRequest::new("alice-a-b-c", EntityType::Organisation, "org-1", "member")
1288                .with_message("");
1289
1290        assert_eq!(request.message, Some(String::new()));
1291    }
1292
1293    #[test]
1294    fn test_invite_request_zero_expiration_allowed() {
1295        // Zero hours means immediate expiration (edge case)
1296        let request = InviteRequest::new("alice-a-b-c", EntityType::Group, "group-1", "viewer")
1297            .with_expiration(0);
1298
1299        assert_eq!(request.expires_in_hours, Some(0));
1300    }
1301
1302    #[test]
1303    fn test_invite_request_long_expiration() {
1304        // 30 days in hours
1305        let request = InviteRequest::new("alice-a-b-c", EntityType::Project, "proj-1", "member")
1306            .with_expiration(720);
1307
1308        assert_eq!(request.expires_in_hours, Some(720));
1309    }
1310
1311    // ============================================
1312    // Integration tests (async with real services)
1313    // ============================================
1314
1315    use crate::crdt_manager::CrdtManager;
1316    use tempfile::tempdir;
1317
1318    /// Create a test InviteService with real EntityService.
1319    async fn create_test_invite_service() -> (InviteService, Arc<EntityService>, String) {
1320        let temp_dir = tempdir().unwrap();
1321        let db_path = temp_dir.path().join("test.db");
1322        let crdt_manager = Arc::new(CrdtManager::new(&db_path).await.unwrap());
1323        let entity_service = Arc::new(EntityService::new(crdt_manager.clone()));
1324        let invite_service = InviteService::new(crdt_manager, entity_service.clone());
1325        // Keep temp_dir to prevent cleanup during test
1326        let temp_path = temp_dir.path().to_string_lossy().to_string();
1327        std::mem::forget(temp_dir); // Prevent cleanup
1328        (invite_service, entity_service, temp_path)
1329    }
1330
1331    #[tokio::test]
1332    async fn test_create_invite_invalid_recipient_format() {
1333        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1334
1335        // Create an organization
1336        let org = entity_service
1337            .create_entity(
1338                "Test Org".to_string(),
1339                EntityType::Organisation,
1340                Some("A test org".to_string()),
1341                "owner-abc-def-ghi".to_string(),
1342                vec![],
1343            )
1344            .await
1345            .expect("Failed to create org");
1346
1347        // Try to create invite with invalid recipient format
1348        let request = InviteRequest::new(
1349            "invalid-format", // Only 2 words instead of 4
1350            EntityType::Organisation,
1351            &org.id,
1352            "member",
1353        );
1354
1355        let result = invite_service
1356            .create_invite("owner-abc-def-ghi", request)
1357            .await;
1358
1359        assert!(matches!(
1360            result,
1361            Err(InviteServiceError::InvalidFourWords(_))
1362        ));
1363    }
1364
1365    #[tokio::test]
1366    async fn test_create_invite_creator_not_member() {
1367        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1368
1369        // Create an organization
1370        let org = entity_service
1371            .create_entity(
1372                "Test Org".to_string(),
1373                EntityType::Organisation,
1374                Some("A test org".to_string()),
1375                "owner-abc-def-ghi".to_string(),
1376                vec![],
1377            )
1378            .await
1379            .expect("Failed to create org");
1380
1381        // Try to create invite as non-member
1382        let request = InviteRequest::new(
1383            "alice-bob-carol-dave",
1384            EntityType::Organisation,
1385            &org.id,
1386            "member",
1387        );
1388
1389        let result = invite_service
1390            .create_invite("other-user-not-member", request)
1391            .await;
1392
1393        // Should fail because creator is not a member
1394        assert!(matches!(
1395            result,
1396            Err(InviteServiceError::MemberNotFound { .. })
1397        ));
1398    }
1399
1400    #[tokio::test]
1401    async fn test_create_invite_success() {
1402        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1403
1404        // Create organization with owner
1405        let creator_id = "owner-abc-def-ghi";
1406        let org = entity_service
1407            .create_entity(
1408                "Test Org".to_string(),
1409                EntityType::Organisation,
1410                Some("A test org".to_string()),
1411                creator_id.to_string(),
1412                vec![],
1413            )
1414            .await
1415            .expect("Failed to create org");
1416
1417        // Set owner role for creator
1418        entity_service
1419            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1420            .await
1421            .expect("Failed to set role");
1422
1423        // Create invite
1424        let request = InviteRequest::new(
1425            "alice-bob-carol-dave",
1426            EntityType::Organisation,
1427            &org.id,
1428            "member",
1429        )
1430        .with_message("Welcome to the team!")
1431        .with_expiration(48);
1432
1433        let invite = invite_service
1434            .create_invite(creator_id, request)
1435            .await
1436            .expect("Failed to create invite");
1437
1438        assert_eq!(invite.creator_id, creator_id);
1439        assert_eq!(invite.recipient_id, "alice-bob-carol-dave");
1440        assert_eq!(invite.entity_id, org.id);
1441        assert_eq!(invite.role, "member");
1442        assert_eq!(invite.message, Some("Welcome to the team!".to_string()));
1443        assert_eq!(invite.status, InviteStatus::Pending);
1444        assert!(invite.is_valid());
1445    }
1446
1447    #[tokio::test]
1448    async fn test_create_invite_role_escalation_blocked() {
1449        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1450
1451        // Create organization with owner
1452        let owner_id = "owner-abc-def-ghi";
1453        let org = entity_service
1454            .create_entity(
1455                "Test Org".to_string(),
1456                EntityType::Organisation,
1457                Some("A test org".to_string()),
1458                owner_id.to_string(),
1459                vec![],
1460            )
1461            .await
1462            .expect("Failed to create org");
1463
1464        // Set member role for a member
1465        let member_id = "member-xyz-uvw-rst";
1466        entity_service
1467            .add_member(EntityType::Organisation, &org.id, member_id, "member")
1468            .await
1469            .expect("Failed to add member");
1470
1471        // Member tries to create invite with admin role
1472        let request = InviteRequest::new(
1473            "alice-bob-carol-dave",
1474            EntityType::Organisation,
1475            &org.id,
1476            "admin", // Higher than member's role
1477        );
1478
1479        let result = invite_service.create_invite(member_id, request).await;
1480
1481        // Member with "member" role doesn't have Edit on Members, so expect PermissionDenied
1482        // not RoleEscalation (which only happens after permissions check passes)
1483        assert!(
1484            matches!(result, Err(InviteServiceError::PermissionDenied(_))),
1485            "Expected PermissionDenied, got: {:?}",
1486            result
1487        );
1488    }
1489
1490    #[tokio::test]
1491    async fn test_create_invite_role_escalation_by_admin() {
1492        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1493
1494        // Create organization with owner
1495        let owner_id = "owner-abc-def-ghi";
1496        let org = entity_service
1497            .create_entity(
1498                "Test Org".to_string(),
1499                EntityType::Organisation,
1500                Some("A test org".to_string()),
1501                owner_id.to_string(),
1502                vec![],
1503            )
1504            .await
1505            .expect("Failed to create org");
1506
1507        // Add admin with "admin" role (can create invites)
1508        let admin_id = "admin-xyz-uvw-rst";
1509        entity_service
1510            .add_member(EntityType::Organisation, &org.id, admin_id, "admin")
1511            .await
1512            .expect("Failed to add admin");
1513
1514        // Admin tries to create invite with owner role (higher than admin's)
1515        let request = InviteRequest::new(
1516            "alice-bob-carol-dave",
1517            EntityType::Organisation,
1518            &org.id,
1519            "owner", // Higher than admin's role
1520        );
1521
1522        let result = invite_service.create_invite(admin_id, request).await;
1523
1524        assert!(
1525            matches!(result, Err(InviteServiceError::RoleEscalation { .. })),
1526            "Expected RoleEscalation, got: {:?}",
1527            result
1528        );
1529    }
1530
1531    #[tokio::test]
1532    async fn test_get_invite() {
1533        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1534
1535        // Create organization with owner
1536        let creator_id = "owner-abc-def-ghi";
1537        let org = entity_service
1538            .create_entity(
1539                "Test Org".to_string(),
1540                EntityType::Organisation,
1541                None,
1542                creator_id.to_string(),
1543                vec![],
1544            )
1545            .await
1546            .expect("Failed to create org");
1547
1548        entity_service
1549            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1550            .await
1551            .expect("Failed to set role");
1552
1553        // Create invite
1554        let request = InviteRequest::new(
1555            "alice-bob-carol-dave",
1556            EntityType::Organisation,
1557            &org.id,
1558            "member",
1559        );
1560
1561        let created_invite = invite_service
1562            .create_invite(creator_id, request)
1563            .await
1564            .expect("Failed to create invite");
1565
1566        // Get invite
1567        let retrieved_invite = invite_service
1568            .get_invite(&created_invite.id)
1569            .await
1570            .expect("Failed to get invite");
1571
1572        assert_eq!(retrieved_invite.id, created_invite.id);
1573        assert_eq!(retrieved_invite.creator_id, created_invite.creator_id);
1574        assert_eq!(retrieved_invite.recipient_id, created_invite.recipient_id);
1575    }
1576
1577    #[tokio::test]
1578    async fn test_get_invite_not_found() {
1579        let (invite_service, _entity_service, _temp) = create_test_invite_service().await;
1580
1581        let result = invite_service.get_invite("nonexistent-id").await;
1582        assert!(matches!(result, Err(InviteServiceError::InviteNotFound(_))));
1583    }
1584
1585    #[tokio::test]
1586    async fn test_accept_invite_success() {
1587        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1588
1589        // Setup: Create org with owner
1590        let creator_id = "owner-abc-def-ghi";
1591        let org = entity_service
1592            .create_entity(
1593                "Test Org".to_string(),
1594                EntityType::Organisation,
1595                None,
1596                creator_id.to_string(),
1597                vec![],
1598            )
1599            .await
1600            .expect("Failed to create org");
1601
1602        entity_service
1603            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1604            .await
1605            .expect("Failed to set role");
1606
1607        // Create invite
1608        let recipient_id = "alice-bob-carol-dave";
1609        let request = InviteRequest::new(recipient_id, EntityType::Organisation, &org.id, "member");
1610
1611        let invite = invite_service
1612            .create_invite(creator_id, request)
1613            .await
1614            .expect("Failed to create invite");
1615
1616        // Accept invite
1617        invite_service
1618            .accept_invite(recipient_id, &invite.id)
1619            .await
1620            .expect("Failed to accept invite");
1621
1622        // Verify invite is accepted
1623        let accepted_invite = invite_service
1624            .get_invite(&invite.id)
1625            .await
1626            .expect("Failed to get invite");
1627
1628        assert_eq!(accepted_invite.status, InviteStatus::Accepted);
1629
1630        // Verify recipient is now a member
1631        let role = entity_service
1632            .get_member_role(EntityType::Organisation, &org.id, recipient_id)
1633            .await
1634            .expect("Failed to get role");
1635
1636        assert_eq!(role, "member");
1637    }
1638
1639    #[tokio::test]
1640    async fn test_accept_invite_wrong_recipient() {
1641        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1642
1643        // Setup
1644        let creator_id = "owner-abc-def-ghi";
1645        let org = entity_service
1646            .create_entity(
1647                "Test Org".to_string(),
1648                EntityType::Organisation,
1649                None,
1650                creator_id.to_string(),
1651                vec![],
1652            )
1653            .await
1654            .expect("Failed to create org");
1655
1656        entity_service
1657            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1658            .await
1659            .expect("Failed to set role");
1660
1661        // Create invite for Alice
1662        let request = InviteRequest::new(
1663            "alice-bob-carol-dave",
1664            EntityType::Organisation,
1665            &org.id,
1666            "member",
1667        );
1668
1669        let invite = invite_service
1670            .create_invite(creator_id, request)
1671            .await
1672            .expect("Failed to create invite");
1673
1674        // Bob tries to accept Alice's invite
1675        let result = invite_service
1676            .accept_invite("bob-xyz-uvw-rst", &invite.id)
1677            .await;
1678
1679        assert!(matches!(
1680            result,
1681            Err(InviteServiceError::PermissionDenied(_))
1682        ));
1683    }
1684
1685    #[tokio::test]
1686    async fn test_reject_invite_success() {
1687        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1688
1689        // Setup
1690        let creator_id = "owner-abc-def-ghi";
1691        let org = entity_service
1692            .create_entity(
1693                "Test Org".to_string(),
1694                EntityType::Organisation,
1695                None,
1696                creator_id.to_string(),
1697                vec![],
1698            )
1699            .await
1700            .expect("Failed to create org");
1701
1702        entity_service
1703            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1704            .await
1705            .expect("Failed to set role");
1706
1707        // Create invite
1708        let recipient_id = "alice-bob-carol-dave";
1709        let request = InviteRequest::new(recipient_id, EntityType::Organisation, &org.id, "member");
1710
1711        let invite = invite_service
1712            .create_invite(creator_id, request)
1713            .await
1714            .expect("Failed to create invite");
1715
1716        // Reject invite
1717        invite_service
1718            .reject_invite(recipient_id, &invite.id)
1719            .await
1720            .expect("Failed to reject invite");
1721
1722        // Verify invite is rejected
1723        let rejected_invite = invite_service
1724            .get_invite(&invite.id)
1725            .await
1726            .expect("Failed to get invite");
1727
1728        assert_eq!(rejected_invite.status, InviteStatus::Rejected);
1729
1730        // Verify recipient is NOT a member
1731        let role_result = entity_service
1732            .get_member_role(EntityType::Organisation, &org.id, recipient_id)
1733            .await;
1734
1735        assert!(role_result.is_err());
1736    }
1737
1738    #[tokio::test]
1739    async fn test_revoke_invite_by_creator() {
1740        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1741
1742        // Setup
1743        let creator_id = "owner-abc-def-ghi";
1744        let org = entity_service
1745            .create_entity(
1746                "Test Org".to_string(),
1747                EntityType::Organisation,
1748                None,
1749                creator_id.to_string(),
1750                vec![],
1751            )
1752            .await
1753            .expect("Failed to create org");
1754
1755        entity_service
1756            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1757            .await
1758            .expect("Failed to set role");
1759
1760        // Create invite
1761        let request = InviteRequest::new(
1762            "alice-bob-carol-dave",
1763            EntityType::Organisation,
1764            &org.id,
1765            "member",
1766        );
1767
1768        let invite = invite_service
1769            .create_invite(creator_id, request)
1770            .await
1771            .expect("Failed to create invite");
1772
1773        // Creator revokes the invite
1774        invite_service
1775            .revoke_invite(creator_id, &invite.id)
1776            .await
1777            .expect("Failed to revoke invite");
1778
1779        // Verify invite is revoked
1780        let revoked_invite = invite_service
1781            .get_invite(&invite.id)
1782            .await
1783            .expect("Failed to get invite");
1784
1785        assert_eq!(revoked_invite.status, InviteStatus::Revoked);
1786    }
1787
1788    #[tokio::test]
1789    async fn test_revoke_invite_by_admin() {
1790        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1791
1792        // Setup: Owner creates org, Admin also exists
1793        let owner_id = "owner-abc-def-ghi";
1794        let admin_id = "admin-xyz-uvw-rst";
1795        let org = entity_service
1796            .create_entity(
1797                "Test Org".to_string(),
1798                EntityType::Organisation,
1799                None,
1800                owner_id.to_string(),
1801                vec![],
1802            )
1803            .await
1804            .expect("Failed to create org");
1805
1806        entity_service
1807            .set_member_role(EntityType::Organisation, &org.id, owner_id, "owner")
1808            .await
1809            .expect("Failed to set owner role");
1810
1811        entity_service
1812            .add_member(EntityType::Organisation, &org.id, admin_id, "admin")
1813            .await
1814            .expect("Failed to add admin");
1815
1816        // Owner creates invite
1817        let request = InviteRequest::new(
1818            "alice-bob-carol-dave",
1819            EntityType::Organisation,
1820            &org.id,
1821            "member",
1822        );
1823
1824        let invite = invite_service
1825            .create_invite(owner_id, request)
1826            .await
1827            .expect("Failed to create invite");
1828
1829        // Admin revokes the invite
1830        invite_service
1831            .revoke_invite(admin_id, &invite.id)
1832            .await
1833            .expect("Admin should be able to revoke");
1834
1835        let revoked_invite = invite_service
1836            .get_invite(&invite.id)
1837            .await
1838            .expect("Failed to get invite");
1839
1840        assert_eq!(revoked_invite.status, InviteStatus::Revoked);
1841    }
1842
1843    #[tokio::test]
1844    async fn test_list_pending_invites() {
1845        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1846
1847        // Setup
1848        let creator_id = "owner-abc-def-ghi";
1849        let recipient_id = "alice-bob-carol-dave";
1850        let org = entity_service
1851            .create_entity(
1852                "Test Org".to_string(),
1853                EntityType::Organisation,
1854                None,
1855                creator_id.to_string(),
1856                vec![],
1857            )
1858            .await
1859            .expect("Failed to create org");
1860
1861        entity_service
1862            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1863            .await
1864            .expect("Failed to set role");
1865
1866        // Create multiple invites for same recipient
1867        for i in 0..3 {
1868            let new_org = entity_service
1869                .create_entity(
1870                    format!("Org {}", i),
1871                    EntityType::Organisation,
1872                    None,
1873                    creator_id.to_string(),
1874                    vec![],
1875                )
1876                .await
1877                .expect("Failed to create org");
1878
1879            entity_service
1880                .set_member_role(EntityType::Organisation, &new_org.id, creator_id, "owner")
1881                .await
1882                .expect("Failed to set role");
1883
1884            let request = InviteRequest::new(
1885                recipient_id,
1886                EntityType::Organisation,
1887                &new_org.id,
1888                "member",
1889            );
1890
1891            invite_service
1892                .create_invite(creator_id, request)
1893                .await
1894                .expect("Failed to create invite");
1895        }
1896
1897        // List pending invites
1898        let pending = invite_service
1899            .list_pending_invites(recipient_id)
1900            .await
1901            .expect("Failed to list invites");
1902
1903        assert_eq!(pending.len(), 3);
1904        for invite in pending {
1905            assert_eq!(invite.status, InviteStatus::Pending);
1906            assert_eq!(invite.recipient_id, recipient_id);
1907        }
1908    }
1909
1910    #[tokio::test]
1911    async fn test_list_entity_invites() {
1912        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1913
1914        // Setup
1915        let creator_id = "owner-abc-def-ghi";
1916        let org = entity_service
1917            .create_entity(
1918                "Test Org".to_string(),
1919                EntityType::Organisation,
1920                None,
1921                creator_id.to_string(),
1922                vec![],
1923            )
1924            .await
1925            .expect("Failed to create org");
1926
1927        entity_service
1928            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1929            .await
1930            .expect("Failed to set role");
1931
1932        // Create multiple invites to same org
1933        let recipients = [
1934            "alice-bob-carol-dave",
1935            "bob-xyz-uvw-rst",
1936            "carol-mno-pqr-stu",
1937        ];
1938        for recipient_id in recipients {
1939            let request =
1940                InviteRequest::new(recipient_id, EntityType::Organisation, &org.id, "member");
1941
1942            invite_service
1943                .create_invite(creator_id, request)
1944                .await
1945                .expect("Failed to create invite");
1946        }
1947
1948        // List invites for entity (requester must be a member)
1949        let entity_invites = invite_service
1950            .list_entity_invites(creator_id, EntityType::Organisation, &org.id)
1951            .await
1952            .expect("Failed to list entity invites");
1953
1954        assert_eq!(entity_invites.len(), 3);
1955        for invite in entity_invites {
1956            assert_eq!(invite.entity_id, org.id);
1957        }
1958    }
1959
1960    #[tokio::test]
1961    async fn test_already_member_blocked() {
1962        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
1963
1964        // Setup
1965        let creator_id = "owner-abc-def-ghi";
1966        let existing_member = "existing-mem-ber-xyz";
1967        let org = entity_service
1968            .create_entity(
1969                "Test Org".to_string(),
1970                EntityType::Organisation,
1971                None,
1972                creator_id.to_string(),
1973                vec![],
1974            )
1975            .await
1976            .expect("Failed to create org");
1977
1978        entity_service
1979            .set_member_role(EntityType::Organisation, &org.id, creator_id, "owner")
1980            .await
1981            .expect("Failed to set role");
1982
1983        // Add existing member
1984        entity_service
1985            .add_member(EntityType::Organisation, &org.id, existing_member, "member")
1986            .await
1987            .expect("Failed to add member");
1988
1989        // Try to create invite for existing member
1990        let request =
1991            InviteRequest::new(existing_member, EntityType::Organisation, &org.id, "member");
1992
1993        let result = invite_service.create_invite(creator_id, request).await;
1994
1995        assert!(matches!(
1996            result,
1997            Err(InviteServiceError::AlreadyMember { .. })
1998        ));
1999    }
2000
2001    #[tokio::test]
2002    async fn test_full_invite_lifecycle() {
2003        let (invite_service, entity_service, _temp) = create_test_invite_service().await;
2004
2005        // 1. Create organization
2006        let owner_id = "owner-abc-def-ghi";
2007        let recipient_id = "alice-bob-carol-dave";
2008
2009        let org = entity_service
2010            .create_entity(
2011                "Test Org".to_string(),
2012                EntityType::Organisation,
2013                Some("A collaborative org".to_string()),
2014                owner_id.to_string(),
2015                vec![],
2016            )
2017            .await
2018            .expect("Failed to create org");
2019
2020        entity_service
2021            .set_member_role(EntityType::Organisation, &org.id, owner_id, "owner")
2022            .await
2023            .expect("Failed to set role");
2024
2025        // 2. Create invite
2026        let request = InviteRequest::new(recipient_id, EntityType::Organisation, &org.id, "member")
2027            .with_message("Welcome!")
2028            .with_expiration(24);
2029
2030        let invite = invite_service
2031            .create_invite(owner_id, request)
2032            .await
2033            .expect("Failed to create invite");
2034
2035        assert_eq!(invite.status, InviteStatus::Pending);
2036        assert!(invite.is_valid());
2037
2038        // 3. List pending invites for recipient
2039        let pending = invite_service
2040            .list_pending_invites(recipient_id)
2041            .await
2042            .expect("Failed to list");
2043
2044        assert_eq!(pending.len(), 1);
2045        assert_eq!(pending[0].id, invite.id);
2046
2047        // 4. Accept invite
2048        invite_service
2049            .accept_invite(recipient_id, &invite.id)
2050            .await
2051            .expect("Failed to accept");
2052
2053        // 5. Verify invite status changed
2054        let accepted = invite_service
2055            .get_invite(&invite.id)
2056            .await
2057            .expect("Failed to get");
2058
2059        assert_eq!(accepted.status, InviteStatus::Accepted);
2060        assert!(!accepted.is_valid()); // No longer valid after acceptance
2061
2062        // 6. Verify member was added
2063        let role = entity_service
2064            .get_member_role(EntityType::Organisation, &org.id, recipient_id)
2065            .await
2066            .expect("Failed to get role");
2067
2068        assert_eq!(role, "member");
2069
2070        // 7. Verify pending list is now empty
2071        let pending_after = invite_service
2072            .list_pending_invites(recipient_id)
2073            .await
2074            .expect("Failed to list");
2075
2076        assert!(pending_after.is_empty());
2077    }
2078}
2079
2080#[cfg(test)]
2081mod proptests {
2082    use super::*;
2083    use proptest::prelude::*;
2084
2085    // Strategy for generating roles
2086    fn role_strategy() -> impl Strategy<Value = String> {
2087        prop_oneof![
2088            Just("owner".to_string()),
2089            Just("admin".to_string()),
2090            Just("member".to_string()),
2091            Just("viewer".to_string()),
2092            Just("guest".to_string()),
2093        ]
2094    }
2095
2096    // Strategy for generating four-word identities
2097    fn four_word_identity() -> impl Strategy<Value = String> {
2098        proptest::collection::vec("[a-z]{3,8}", 4).prop_map(|words| words.join("-"))
2099    }
2100
2101    proptest! {
2102        /// Property: role_rank is monotonic with the role hierarchy.
2103        #[test]
2104        fn prop_role_rank_monotonic(
2105            role1 in role_strategy(),
2106            role2 in role_strategy(),
2107        ) {
2108            let rank1 = role_rank(&role1);
2109            let rank2 = role_rank(&role2);
2110
2111            // If rank1 >= rank2, then can_grant_role should be true
2112            prop_assert_eq!(can_grant_role(&role1, &role2), rank1 >= rank2);
2113        }
2114
2115        /// Property: can_grant_role is reflexive (can always grant own role).
2116        #[test]
2117        fn prop_can_grant_role_reflexive(role in role_strategy()) {
2118            prop_assert!(can_grant_role(&role, &role));
2119        }
2120
2121        /// Property: can_grant_role is transitive.
2122        #[test]
2123        fn prop_can_grant_role_transitive(
2124            role_a in role_strategy(),
2125            role_b in role_strategy(),
2126            role_c in role_strategy(),
2127        ) {
2128            // If A can grant B and B can grant C, then A can grant C
2129            if can_grant_role(&role_a, &role_b) && can_grant_role(&role_b, &role_c) {
2130                prop_assert!(can_grant_role(&role_a, &role_c));
2131            }
2132        }
2133
2134        /// Property: validate_four_words_format accepts valid four-word identities.
2135        #[test]
2136        fn prop_valid_four_words_accepted(identity in four_word_identity()) {
2137            prop_assert!(validate_four_words_format(&identity));
2138        }
2139
2140        /// Property: validate_four_words_format rejects identities with wrong word count.
2141        #[test]
2142        fn prop_wrong_word_count_rejected(
2143            word_count in (1usize..10).prop_filter("not 4", |&c| c != 4),
2144        ) {
2145            let words: Vec<String> = (0..word_count).map(|i| format!("word{}", i)).collect();
2146            let identity = words.join("-");
2147            prop_assert!(!validate_four_words_format(&identity));
2148        }
2149
2150        /// Property: role_rank returns 0 for unknown roles.
2151        #[test]
2152        fn prop_unknown_role_rank_zero(
2153            unknown in "[a-z]{5,10}".prop_filter("not standard", |s| {
2154                !["owner", "admin", "member", "viewer", "guest"].contains(&s.as_str())
2155            }),
2156        ) {
2157            prop_assert_eq!(role_rank(&unknown), 0);
2158        }
2159
2160        /// Property: InviteRequest builder preserves required fields through chaining.
2161        #[test]
2162        fn prop_invite_request_preserves_required_fields(
2163            recipient in four_word_identity(),
2164            entity_id in "[a-z0-9-]{8,16}",
2165            role in role_strategy(),
2166            message in proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2167            hours in proptest::option::of(0u32..1000),
2168        ) {
2169            let mut request = InviteRequest::new(
2170                recipient.clone(),
2171                EntityType::Organisation,
2172                entity_id.clone(),
2173                role.clone(),
2174            );
2175
2176            if let Some(msg) = &message {
2177                request = request.with_message(msg.clone());
2178            }
2179            if let Some(h) = hours {
2180                request = request.with_expiration(h);
2181            }
2182
2183            // Required fields must be preserved
2184            prop_assert_eq!(request.recipient_id, recipient);
2185            prop_assert_eq!(request.entity_id, entity_id);
2186            prop_assert_eq!(request.role, role);
2187            prop_assert_eq!(request.entity_type, EntityType::Organisation);
2188        }
2189
2190        /// Property: Builder order does not affect final state.
2191        #[test]
2192        fn prop_builder_order_independence(
2193            recipient in four_word_identity(),
2194            entity_id in "[a-z0-9-]{8,16}",
2195            role in role_strategy(),
2196            message in "[a-zA-Z0-9 ]{1,30}",
2197            hours in 1u32..500,
2198        ) {
2199            // Message first, then expiration
2200            let req1 = InviteRequest::new(
2201                recipient.clone(),
2202                EntityType::Group,
2203                entity_id.clone(),
2204                role.clone(),
2205            )
2206            .with_message(message.clone())
2207            .with_expiration(hours);
2208
2209            // Expiration first, then message
2210            let req2 = InviteRequest::new(
2211                recipient,
2212                EntityType::Group,
2213                entity_id,
2214                role,
2215            )
2216            .with_expiration(hours)
2217            .with_message(message);
2218
2219            prop_assert_eq!(req1.recipient_id, req2.recipient_id);
2220            prop_assert_eq!(req1.entity_id, req2.entity_id);
2221            prop_assert_eq!(req1.role, req2.role);
2222            prop_assert_eq!(req1.message, req2.message);
2223            prop_assert_eq!(req1.expires_in_hours, req2.expires_in_hours);
2224        }
2225    }
2226}
2227
2228/// Property-based tests for CRDT-relevant invite operations.
2229///
2230/// These tests verify properties that are essential for CRDT storage:
2231/// - Terminal status transitions are irreversible
2232/// - Operations are idempotent
2233/// - State is deterministic based on timestamps
2234/// - Serialization preserves all fields
2235#[cfg(test)]
2236mod crdt_proptests {
2237    use super::*;
2238    use crate::invite::{Invite, InviteActionError, InviteStatus};
2239    use chrono::{Duration, Utc};
2240    use proptest::prelude::*;
2241
2242    // Strategy for generating four-word identities
2243    fn four_word_identity() -> impl Strategy<Value = String> {
2244        proptest::collection::vec("[a-z]{3,8}", 4).prop_map(|words| words.join("-"))
2245    }
2246
2247    // Strategy for generating roles
2248    fn role_strategy() -> impl Strategy<Value = String> {
2249        prop_oneof![
2250            Just("owner".to_string()),
2251            Just("admin".to_string()),
2252            Just("member".to_string()),
2253            Just("viewer".to_string()),
2254            Just("guest".to_string()),
2255        ]
2256    }
2257
2258    // Strategy for generating entity types
2259    fn entity_type_strategy() -> impl Strategy<Value = EntityType> {
2260        prop_oneof![
2261            Just(EntityType::Organisation),
2262            Just(EntityType::Group),
2263            Just(EntityType::Channel),
2264            Just(EntityType::Project),
2265        ]
2266    }
2267
2268    // Strategy for generating terminal status values
2269    fn terminal_status_strategy() -> impl Strategy<Value = InviteStatus> {
2270        prop_oneof![
2271            Just(InviteStatus::Accepted),
2272            Just(InviteStatus::Rejected),
2273            Just(InviteStatus::Expired),
2274            Just(InviteStatus::Revoked),
2275        ]
2276    }
2277
2278    // Strategy for creating a valid invite
2279    fn invite_strategy() -> impl Strategy<Value = Invite> {
2280        (
2281            four_word_identity(),
2282            four_word_identity(),
2283            "[a-z0-9-]{8,16}",
2284            entity_type_strategy(),
2285            role_strategy(),
2286            proptest::option::of("[a-zA-Z0-9 ]{1,50}"),
2287            proptest::option::of(1u32..1000),
2288        )
2289            .prop_map(
2290                |(creator, recipient, entity_id, entity_type, role, message, expires)| {
2291                    Invite::new(
2292                        creator,
2293                        recipient,
2294                        entity_id,
2295                        entity_type,
2296                        role,
2297                        message,
2298                        expires,
2299                    )
2300                },
2301            )
2302    }
2303
2304    proptest! {
2305        /// Property: Terminal status is permanent - once an invite is resolved, it cannot change.
2306        ///
2307        /// This is crucial for CRDT semantics: terminal states must be stable regardless
2308        /// of operation order when replaying on different nodes.
2309        #[test]
2310        fn prop_terminal_status_is_permanent(
2311            invite in invite_strategy(),
2312            terminal_status in terminal_status_strategy(),
2313            actor in four_word_identity(),
2314        ) {
2315            let mut invite = invite;
2316            let recipient = invite.recipient_id.clone();
2317            // Force to terminal state
2318            invite.status = terminal_status;
2319            invite.resolved_at = Some(Utc::now().timestamp_millis());
2320
2321            // Try accept - should fail
2322            let accept_result = invite.accept(&recipient);
2323            prop_assert!(accept_result.is_err());
2324            if let Err(InviteActionError::AlreadyResolved(s)) = accept_result {
2325                prop_assert_eq!(s, terminal_status);
2326            } else {
2327                prop_assert!(false, "Expected AlreadyResolved error");
2328            }
2329            prop_assert_eq!(invite.status, terminal_status);
2330
2331            // Try reject - should fail
2332            let reject_result = invite.reject(&recipient);
2333            prop_assert!(reject_result.is_err());
2334            prop_assert_eq!(invite.status, terminal_status);
2335
2336            // Try revoke - should fail
2337            let revoke_result = invite.revoke(&actor);
2338            prop_assert!(revoke_result.is_err());
2339            prop_assert_eq!(invite.status, terminal_status);
2340
2341            // Try mark_expired - should fail
2342            let expire_result = invite.mark_expired();
2343            prop_assert!(expire_result.is_err());
2344            prop_assert_eq!(invite.status, terminal_status);
2345        }
2346
2347        /// Property: Accept operation is idempotent on the error case.
2348        ///
2349        /// Once accepted, repeated accept calls return the same error.
2350        #[test]
2351        fn prop_accept_idempotent_after_success(
2352            invite in invite_strategy(),
2353        ) {
2354            let mut invite = invite;
2355            let recipient = invite.recipient_id.clone();
2356
2357            // First accept succeeds
2358            let result1 = invite.accept(&recipient);
2359            prop_assert!(result1.is_ok());
2360            prop_assert_eq!(invite.status, InviteStatus::Accepted);
2361
2362            let resolved_at1 = invite.resolved_at;
2363
2364            // Second accept fails with AlreadyResolved
2365            let result2 = invite.accept(&recipient);
2366            prop_assert!(matches!(result2, Err(InviteActionError::AlreadyResolved(InviteStatus::Accepted))));
2367
2368            // State unchanged
2369            prop_assert_eq!(invite.status, InviteStatus::Accepted);
2370            prop_assert_eq!(invite.resolved_at, resolved_at1);
2371        }
2372
2373        /// Property: Reject operation is idempotent on the error case.
2374        #[test]
2375        fn prop_reject_idempotent_after_success(
2376            invite in invite_strategy(),
2377        ) {
2378            let mut invite = invite;
2379            let recipient = invite.recipient_id.clone();
2380
2381            // First reject succeeds
2382            let result1 = invite.reject(&recipient);
2383            prop_assert!(result1.is_ok());
2384            prop_assert_eq!(invite.status, InviteStatus::Rejected);
2385
2386            let resolved_at1 = invite.resolved_at;
2387
2388            // Second reject fails with AlreadyResolved
2389            let result2 = invite.reject(&recipient);
2390            prop_assert!(matches!(result2, Err(InviteActionError::AlreadyResolved(InviteStatus::Rejected))));
2391
2392            // State unchanged
2393            prop_assert_eq!(invite.status, InviteStatus::Rejected);
2394            prop_assert_eq!(invite.resolved_at, resolved_at1);
2395        }
2396
2397        /// Property: Revoke operation is idempotent on the error case.
2398        #[test]
2399        fn prop_revoke_idempotent_after_success(
2400            invite in invite_strategy(),
2401            revoker in four_word_identity(),
2402        ) {
2403            let mut invite = invite;
2404
2405            // First revoke succeeds
2406            let result1 = invite.revoke(&revoker);
2407            prop_assert!(result1.is_ok());
2408            prop_assert_eq!(invite.status, InviteStatus::Revoked);
2409
2410            let resolved_at1 = invite.resolved_at;
2411
2412            // Second revoke fails with AlreadyResolved
2413            let result2 = invite.revoke(&revoker);
2414            prop_assert!(matches!(result2, Err(InviteActionError::AlreadyResolved(InviteStatus::Revoked))));
2415
2416            // State unchanged
2417            prop_assert_eq!(invite.status, InviteStatus::Revoked);
2418            prop_assert_eq!(invite.resolved_at, resolved_at1);
2419        }
2420
2421        /// Property: Expiration is deterministic based on timestamps.
2422        ///
2423        /// The same invite checked at the same time always gives the same result.
2424        #[test]
2425        fn prop_expiration_deterministic(
2426            invite in invite_strategy(),
2427            hours_offset in -100i64..100,
2428        ) {
2429            let check_time = Utc::now() + Duration::hours(hours_offset);
2430
2431            // Check multiple times at the same timestamp
2432            let expired1 = invite.is_expired_at(check_time);
2433            let expired2 = invite.is_expired_at(check_time);
2434            let expired3 = invite.is_expired_at(check_time);
2435
2436            prop_assert_eq!(expired1, expired2);
2437            prop_assert_eq!(expired2, expired3);
2438        }
2439
2440        /// Property: Expiration monotonicity - once expired, always expired (at same or later time).
2441        #[test]
2442        fn prop_expiration_monotonic(
2443            invite in invite_strategy(),
2444        ) {
2445            if let Some(expires_at) = invite.expires_at {
2446                // Time after expiration
2447                let after_expiry = chrono::DateTime::from_timestamp_millis(expires_at + 1000)
2448                    .unwrap_or_else(Utc::now);
2449                let later_still = after_expiry + Duration::hours(1);
2450
2451                // Should be expired at both times
2452                prop_assert!(invite.is_expired_at(after_expiry));
2453                prop_assert!(invite.is_expired_at(later_still));
2454            }
2455        }
2456
2457        /// Property: is_valid is consistent with is_pending and is_expired.
2458        #[test]
2459        fn prop_is_valid_consistent(
2460            invite in invite_strategy(),
2461            hours_offset in -100i64..100,
2462        ) {
2463            let check_time = Utc::now() + Duration::hours(hours_offset);
2464
2465            let is_valid = invite.is_valid_at(check_time);
2466            let is_pending = invite.is_pending();
2467            let is_expired = invite.is_expired_at(check_time);
2468
2469            // is_valid should be: pending AND NOT expired
2470            prop_assert_eq!(is_valid, is_pending && !is_expired);
2471        }
2472
2473        /// Property: Only recipient can accept or reject.
2474        #[test]
2475        fn prop_only_recipient_can_accept_or_reject(
2476            invite in invite_strategy(),
2477            wrong_actor in four_word_identity(),
2478        ) {
2479            // Skip if wrong_actor happens to match recipient (very unlikely)
2480            prop_assume!(wrong_actor != invite.recipient_id);
2481
2482            let mut invite_for_accept = invite.clone();
2483            let mut invite_for_reject = invite;
2484            let expected_recipient_accept = invite_for_accept.recipient_id.clone();
2485            let expected_recipient_reject = invite_for_reject.recipient_id.clone();
2486
2487            // Accept with wrong actor fails
2488            let accept_result = invite_for_accept.accept(&wrong_actor);
2489            prop_assert!(accept_result.is_err());
2490            if let Err(InviteActionError::NotRecipient { expected, actual }) = accept_result {
2491                prop_assert_eq!(expected, expected_recipient_accept);
2492                prop_assert_eq!(actual, wrong_actor.clone());
2493            } else {
2494                prop_assert!(false, "Expected NotRecipient error");
2495            }
2496            prop_assert!(invite_for_accept.is_pending()); // Status unchanged
2497
2498            // Reject with wrong actor fails
2499            let reject_result = invite_for_reject.reject(&wrong_actor);
2500            prop_assert!(reject_result.is_err());
2501            if let Err(InviteActionError::NotRecipient { expected, actual }) = reject_result {
2502                prop_assert_eq!(expected, expected_recipient_reject);
2503                prop_assert_eq!(actual, wrong_actor);
2504            } else {
2505                prop_assert!(false, "Expected NotRecipient error");
2506            }
2507            prop_assert!(invite_for_reject.is_pending()); // Status unchanged
2508        }
2509
2510        /// Property: Revoke can be done by anyone (authorization is at service level).
2511        ///
2512        /// The Invite model itself doesn't check who can revoke - that's
2513        /// done by InviteService which checks permissions.
2514        #[test]
2515        fn prop_revoke_accepts_any_actor(
2516            invite in invite_strategy(),
2517            any_actor in four_word_identity(),
2518        ) {
2519            let mut invite = invite;
2520
2521            // Any actor can revoke at the model level
2522            let result = invite.revoke(&any_actor);
2523            prop_assert!(result.is_ok());
2524            prop_assert_eq!(invite.status, InviteStatus::Revoked);
2525            prop_assert_eq!(invite.resolved_by, Some(any_actor));
2526        }
2527
2528        /// Property: Serialization roundtrip preserves all fields.
2529        ///
2530        /// Essential for CRDT storage: data must survive serialization.
2531        #[test]
2532        fn prop_serialization_roundtrip(
2533            invite in invite_strategy(),
2534        ) {
2535            // Serialize to JSON
2536            let json = serde_json::to_string(&invite)
2537                .expect("serialization should succeed");
2538
2539            // Deserialize back
2540            let restored: Invite = serde_json::from_str(&json)
2541                .expect("deserialization should succeed");
2542
2543            // All fields preserved
2544            prop_assert_eq!(invite.id, restored.id);
2545            prop_assert_eq!(invite.creator_id, restored.creator_id);
2546            prop_assert_eq!(invite.recipient_id, restored.recipient_id);
2547            prop_assert_eq!(invite.entity_id, restored.entity_id);
2548            prop_assert_eq!(invite.entity_type, restored.entity_type);
2549            prop_assert_eq!(invite.role, restored.role);
2550            prop_assert_eq!(invite.message, restored.message);
2551            prop_assert_eq!(invite.status, restored.status);
2552            prop_assert_eq!(invite.created_at, restored.created_at);
2553            prop_assert_eq!(invite.expires_at, restored.expires_at);
2554            prop_assert_eq!(invite.resolved_at, restored.resolved_at);
2555            prop_assert_eq!(invite.resolved_by, restored.resolved_by);
2556        }
2557
2558        /// Property: InviteStatus serialization roundtrip for all statuses.
2559        #[test]
2560        fn prop_invite_status_serialization(
2561            status in prop_oneof![
2562                Just(InviteStatus::Pending),
2563                Just(InviteStatus::Accepted),
2564                Just(InviteStatus::Rejected),
2565                Just(InviteStatus::Expired),
2566                Just(InviteStatus::Revoked),
2567            ],
2568        ) {
2569            // Serialize
2570            let json = serde_json::to_string(&status)
2571                .expect("status serialization should succeed");
2572
2573            // Deserialize
2574            let restored: InviteStatus = serde_json::from_str(&json)
2575                .expect("status deserialization should succeed");
2576
2577            prop_assert_eq!(status, restored);
2578
2579            // Also test Display/FromStr roundtrip
2580            let display = status.to_string();
2581            let parsed: InviteStatus = display.parse()
2582                .expect("status parse should succeed");
2583            prop_assert_eq!(status, parsed);
2584        }
2585
2586        /// Property: Resolved invites record resolution metadata.
2587        #[test]
2588        fn prop_resolution_records_metadata(
2589            invite in invite_strategy(),
2590            actor in four_word_identity(),
2591        ) {
2592            // Test accept
2593            let mut invite_accept = invite.clone();
2594            let recipient = invite_accept.recipient_id.clone();
2595            if invite_accept.accept(&recipient).is_ok() {
2596                prop_assert!(invite_accept.resolved_at.is_some());
2597                prop_assert_eq!(invite_accept.resolved_by, Some(recipient.clone()));
2598            }
2599
2600            // Test reject
2601            let mut invite_reject = invite.clone();
2602            let recipient = invite_reject.recipient_id.clone();
2603            if invite_reject.reject(&recipient).is_ok() {
2604                prop_assert!(invite_reject.resolved_at.is_some());
2605                prop_assert_eq!(invite_reject.resolved_by, Some(recipient.clone()));
2606            }
2607
2608            // Test revoke
2609            let mut invite_revoke = invite;
2610            if invite_revoke.revoke(&actor).is_ok() {
2611                prop_assert!(invite_revoke.resolved_at.is_some());
2612                prop_assert_eq!(invite_revoke.resolved_by, Some(actor));
2613            }
2614        }
2615
2616        /// Property: New invites start in pending status.
2617        #[test]
2618        fn prop_new_invite_pending(
2619            creator in four_word_identity(),
2620            recipient in four_word_identity(),
2621            entity_id in "[a-z0-9-]{8,16}",
2622            entity_type in entity_type_strategy(),
2623            role in role_strategy(),
2624        ) {
2625            let invite = Invite::new(
2626                creator,
2627                recipient,
2628                entity_id,
2629                entity_type,
2630                role,
2631                None,
2632                None,
2633            );
2634
2635            prop_assert!(invite.is_pending());
2636            prop_assert!(!invite.is_resolved());
2637            prop_assert!(invite.resolved_at.is_none());
2638            prop_assert!(invite.resolved_by.is_none());
2639        }
2640
2641        /// Property: Invite without expiration never expires.
2642        #[test]
2643        fn prop_no_expiration_never_expires(
2644            creator in four_word_identity(),
2645            recipient in four_word_identity(),
2646            entity_id in "[a-z0-9-]{8,16}",
2647            hours_in_future in 0i64..100000,
2648        ) {
2649            let invite = Invite::new(
2650                creator,
2651                recipient,
2652                entity_id,
2653                EntityType::Organisation,
2654                "member".to_string(),
2655                None,
2656                None, // No expiration
2657            );
2658
2659            // Check at any future time
2660            let future_time = Utc::now() + Duration::hours(hours_in_future);
2661            prop_assert!(!invite.is_expired_at(future_time));
2662        }
2663
2664        /// Property: First action on pending invite determines final state.
2665        ///
2666        /// This simulates CRDT conflict resolution: whichever action happens
2667        /// first (by timestamp) wins and subsequent actions fail.
2668        #[test]
2669        fn prop_first_action_wins(
2670            invite in invite_strategy(),
2671            action_type in 0u8..3,
2672            revoker in four_word_identity(),
2673        ) {
2674            let mut invite = invite;
2675            let recipient = invite.recipient_id.clone();
2676
2677            // Perform first action based on type
2678            let first_status = match action_type {
2679                0 => {
2680                    let _ = invite.accept(&recipient);
2681                    InviteStatus::Accepted
2682                }
2683                1 => {
2684                    let _ = invite.reject(&recipient);
2685                    InviteStatus::Rejected
2686                }
2687                _ => {
2688                    let _ = invite.revoke(&revoker);
2689                    InviteStatus::Revoked
2690                }
2691            };
2692
2693            // Now the invite should be in the terminal state
2694            prop_assert_eq!(invite.status, first_status);
2695            prop_assert!(invite.is_resolved());
2696
2697            // All subsequent actions should fail
2698            prop_assert!(invite.accept(&recipient).is_err());
2699            prop_assert!(invite.reject(&recipient).is_err());
2700            prop_assert!(invite.revoke(&revoker).is_err());
2701        }
2702    }
2703}
2704
2705/// Tests for CRDT storage implementation.
2706///
2707/// These tests verify that invites are correctly stored and retrieved
2708/// from Yrs CRDT documents, enabling offline-first sync.
2709#[cfg(test)]
2710mod crdt_storage_tests {
2711    use super::*;
2712    use crate::crdt_manager::CrdtManager;
2713    use crate::entity_service::EntityService;
2714    use crate::invite::{Invite, InviteStatus};
2715    use crate::legacy_crdt::EntityType;
2716    use std::sync::Arc;
2717    use tempfile::TempDir;
2718
2719    /// Helper to create a test CrdtManager with temporary storage.
2720    async fn create_test_crdt_manager() -> (Arc<CrdtManager>, TempDir) {
2721        let temp_dir = TempDir::new().expect("failed to create temp dir");
2722        let crdt_manager = CrdtManager::new(temp_dir.path())
2723            .await
2724            .expect("failed to create CrdtManager");
2725        (Arc::new(crdt_manager), temp_dir)
2726    }
2727
2728    /// Helper to create a test EntityService with a shared CrdtManager.
2729    fn create_test_entity_service(crdt_manager: Arc<CrdtManager>) -> Arc<EntityService> {
2730        Arc::new(EntityService::new(crdt_manager))
2731    }
2732
2733    /// Helper to create test InviteService with real CRDT manager.
2734    async fn create_test_invite_service() -> (InviteService, TempDir) {
2735        let (crdt_manager, temp_dir) = create_test_crdt_manager().await;
2736        let entity_service = create_test_entity_service(crdt_manager.clone());
2737        let service = InviteService::new(crdt_manager, entity_service);
2738        (service, temp_dir)
2739    }
2740
2741    /// Helper to create a test invite.
2742    fn create_test_invite(recipient: &str, entity_type: EntityType, entity_id: &str) -> Invite {
2743        Invite::new(
2744            "alice-test-four-word".to_string(),
2745            recipient.to_string(),
2746            entity_id.to_string(),
2747            entity_type,
2748            "member".to_string(),
2749            Some("Welcome!".to_string()),
2750            Some(24), // expires in 24 hours
2751        )
2752    }
2753
2754    // ============================================
2755    // CRDT Storage Tests (TDD - write tests first)
2756    // ============================================
2757
2758    #[tokio::test]
2759    async fn test_store_and_load_invite_from_crdt() {
2760        let (service, _temp_dir) = create_test_invite_service().await;
2761
2762        let invite = create_test_invite("bob-test-four-word", EntityType::Organisation, "org-123");
2763        let invite_id = invite.id.clone();
2764
2765        // Store the invite in CRDT
2766        service
2767            .store_invite_in_crdt(&invite)
2768            .await
2769            .expect("failed to store invite");
2770
2771        // Load it back from CRDT
2772        let loaded = service
2773            .load_invite_from_crdt(&invite_id)
2774            .await
2775            .expect("failed to load invite");
2776
2777        // Verify all fields
2778        assert_eq!(loaded.id, invite.id);
2779        assert_eq!(loaded.creator_id, invite.creator_id);
2780        assert_eq!(loaded.recipient_id, invite.recipient_id);
2781        assert_eq!(loaded.entity_id, invite.entity_id);
2782        assert_eq!(loaded.entity_type, invite.entity_type);
2783        assert_eq!(loaded.role, invite.role);
2784        assert_eq!(loaded.message, invite.message);
2785        assert_eq!(loaded.status, invite.status);
2786        assert_eq!(loaded.created_at, invite.created_at);
2787        assert_eq!(loaded.expires_at, invite.expires_at);
2788    }
2789
2790    #[tokio::test]
2791    async fn test_load_nonexistent_invite_returns_not_found() {
2792        let (service, _temp_dir) = create_test_invite_service().await;
2793
2794        let result = service.load_invite_from_crdt("nonexistent-invite-id").await;
2795
2796        assert!(matches!(result, Err(InviteServiceError::InviteNotFound(_))));
2797    }
2798
2799    #[tokio::test]
2800    async fn test_store_invite_updates_existing() {
2801        let (service, _temp_dir) = create_test_invite_service().await;
2802
2803        let mut invite =
2804            create_test_invite("bob-test-four-word", EntityType::Organisation, "org-123");
2805        let invite_id = invite.id.clone();
2806
2807        // Store initial invite
2808        service
2809            .store_invite_in_crdt(&invite)
2810            .await
2811            .expect("failed to store invite");
2812
2813        // Modify the invite (accept it)
2814        let recipient = invite.recipient_id.clone();
2815        invite.accept(&recipient).expect("failed to accept");
2816
2817        // Store updated invite
2818        service
2819            .store_invite_in_crdt(&invite)
2820            .await
2821            .expect("failed to store updated invite");
2822
2823        // Load and verify update was persisted
2824        let loaded = service
2825            .load_invite_from_crdt(&invite_id)
2826            .await
2827            .expect("failed to load invite");
2828
2829        assert_eq!(loaded.status, InviteStatus::Accepted);
2830        assert!(loaded.resolved_at.is_some());
2831        assert_eq!(loaded.resolved_by, Some(recipient));
2832    }
2833
2834    #[tokio::test]
2835    async fn test_load_invites_for_entity_from_crdt() {
2836        let (service, _temp_dir) = create_test_invite_service().await;
2837
2838        let entity_type = EntityType::Organisation;
2839        let entity_id = "org-456";
2840
2841        // Store multiple invites for the same entity
2842        let invite1 = create_test_invite("bob-one-two-three", entity_type, entity_id);
2843        let invite2 = create_test_invite("carol-four-five-six", entity_type, entity_id);
2844        let invite3 = create_test_invite("dave-seven-eight-nine", EntityType::Group, "group-789");
2845
2846        service
2847            .store_invite_in_crdt(&invite1)
2848            .await
2849            .expect("store 1");
2850        service
2851            .store_invite_in_crdt(&invite2)
2852            .await
2853            .expect("store 2");
2854        service
2855            .store_invite_in_crdt(&invite3)
2856            .await
2857            .expect("store 3");
2858
2859        // Load invites for the specific entity
2860        let invites = service
2861            .load_invites_for_entity_from_crdt(entity_type, entity_id)
2862            .await
2863            .expect("failed to load entity invites");
2864
2865        // Should only get the 2 invites for org-456
2866        assert_eq!(invites.len(), 2);
2867
2868        let invite_ids: Vec<&str> = invites.iter().map(|i| i.id.as_str()).collect();
2869        assert!(invite_ids.contains(&invite1.id.as_str()));
2870        assert!(invite_ids.contains(&invite2.id.as_str()));
2871        assert!(!invite_ids.contains(&invite3.id.as_str()));
2872    }
2873
2874    #[tokio::test]
2875    async fn test_load_invites_for_empty_entity_returns_empty_vec() {
2876        let (service, _temp_dir) = create_test_invite_service().await;
2877
2878        let invites = service
2879            .load_invites_for_entity_from_crdt(EntityType::Organisation, "nonexistent-entity")
2880            .await
2881            .expect("should return empty vec");
2882
2883        assert!(invites.is_empty());
2884    }
2885
2886    #[tokio::test]
2887    async fn test_crdt_storage_survives_service_restart() {
2888        let temp_dir = TempDir::new().expect("failed to create temp dir");
2889
2890        let invite_id: String;
2891        let original_recipient: String;
2892
2893        // First service instance - store an invite
2894        {
2895            let crdt_manager = Arc::new(
2896                CrdtManager::new(temp_dir.path())
2897                    .await
2898                    .expect("failed to create CrdtManager"),
2899            );
2900            let entity_service = create_test_entity_service(crdt_manager.clone());
2901            let service = InviteService::new(crdt_manager, entity_service);
2902
2903            let invite =
2904                create_test_invite("bob-test-four-word", EntityType::Organisation, "org-123");
2905            invite_id = invite.id.clone();
2906            original_recipient = invite.recipient_id.clone();
2907
2908            service
2909                .store_invite_in_crdt(&invite)
2910                .await
2911                .expect("failed to store invite");
2912        }
2913
2914        // Second service instance (simulates restart) - load the invite
2915        {
2916            let crdt_manager = Arc::new(
2917                CrdtManager::new(temp_dir.path())
2918                    .await
2919                    .expect("failed to create CrdtManager"),
2920            );
2921            let entity_service = create_test_entity_service(crdt_manager.clone());
2922            let service = InviteService::new(crdt_manager, entity_service);
2923
2924            let loaded = service
2925                .load_invite_from_crdt(&invite_id)
2926                .await
2927                .expect("failed to load invite after restart");
2928
2929            assert_eq!(loaded.id, invite_id);
2930            assert_eq!(loaded.recipient_id, original_recipient);
2931            assert_eq!(loaded.status, InviteStatus::Pending);
2932        }
2933    }
2934
2935    #[tokio::test]
2936    async fn test_multiple_entities_have_separate_crdt_docs() {
2937        let (service, _temp_dir) = create_test_invite_service().await;
2938
2939        // Store invites for different entity types
2940        let org_invite =
2941            create_test_invite("bob-org-four-word", EntityType::Organisation, "org-123");
2942        let group_invite =
2943            create_test_invite("carol-group-four-word", EntityType::Group, "group-456");
2944        let project_invite =
2945            create_test_invite("dave-project-four-word", EntityType::Project, "project-789");
2946
2947        service
2948            .store_invite_in_crdt(&org_invite)
2949            .await
2950            .expect("store org");
2951        service
2952            .store_invite_in_crdt(&group_invite)
2953            .await
2954            .expect("store group");
2955        service
2956            .store_invite_in_crdt(&project_invite)
2957            .await
2958            .expect("store project");
2959
2960        // Each entity should have exactly one invite
2961        let org_invites = service
2962            .load_invites_for_entity_from_crdt(EntityType::Organisation, "org-123")
2963            .await
2964            .expect("org invites");
2965        assert_eq!(org_invites.len(), 1);
2966        assert_eq!(org_invites[0].id, org_invite.id);
2967
2968        let group_invites = service
2969            .load_invites_for_entity_from_crdt(EntityType::Group, "group-456")
2970            .await
2971            .expect("group invites");
2972        assert_eq!(group_invites.len(), 1);
2973        assert_eq!(group_invites[0].id, group_invite.id);
2974
2975        let project_invites = service
2976            .load_invites_for_entity_from_crdt(EntityType::Project, "project-789")
2977            .await
2978            .expect("project invites");
2979        assert_eq!(project_invites.len(), 1);
2980        assert_eq!(project_invites[0].id, project_invite.id);
2981    }
2982
2983    #[tokio::test]
2984    async fn test_store_invite_with_all_statuses() {
2985        let (service, _temp_dir) = create_test_invite_service().await;
2986
2987        // Test each status can be stored and retrieved
2988        let statuses = [
2989            InviteStatus::Pending,
2990            InviteStatus::Accepted,
2991            InviteStatus::Rejected,
2992            InviteStatus::Revoked,
2993            InviteStatus::Expired,
2994        ];
2995
2996        for (i, status) in statuses.iter().enumerate() {
2997            let mut invite = create_test_invite(
2998                &format!("bob-{}-four-word", i),
2999                EntityType::Organisation,
3000                &format!("org-status-{}", i),
3001            );
3002
3003            // Manually set status for testing
3004            match status {
3005                InviteStatus::Pending => {}
3006                InviteStatus::Accepted => {
3007                    let recipient = invite.recipient_id.clone();
3008                    invite.accept(&recipient).expect("accept");
3009                }
3010                InviteStatus::Rejected => {
3011                    let recipient = invite.recipient_id.clone();
3012                    invite.reject(&recipient).expect("reject");
3013                }
3014                InviteStatus::Revoked => {
3015                    invite.revoke("admin-a-b-c").expect("revoke");
3016                }
3017                InviteStatus::Expired => {
3018                    invite.mark_expired().expect("mark expired");
3019                }
3020            }
3021
3022            let invite_id = invite.id.clone();
3023            service
3024                .store_invite_in_crdt(&invite)
3025                .await
3026                .unwrap_or_else(|_| panic!("store {} failed", status));
3027
3028            let loaded = service
3029                .load_invite_from_crdt(&invite_id)
3030                .await
3031                .unwrap_or_else(|_| panic!("load {} failed", status));
3032
3033            assert_eq!(loaded.status, *status, "status mismatch for {}", status);
3034        }
3035    }
3036
3037    #[tokio::test]
3038    async fn test_recipient_index_persisted_in_crdt() {
3039        let temp_dir = TempDir::new().expect("failed to create temp dir");
3040
3041        let recipient = "bob-index-test-word";
3042        let invite1_id: String;
3043        let invite2_id: String;
3044
3045        // First instance - store invites for same recipient
3046        {
3047            let crdt_manager = Arc::new(
3048                CrdtManager::new(temp_dir.path())
3049                    .await
3050                    .expect("failed to create CrdtManager"),
3051            );
3052            let entity_service = create_test_entity_service(crdt_manager.clone());
3053            let service = InviteService::new(crdt_manager, entity_service);
3054
3055            let invite1 = create_test_invite(recipient, EntityType::Organisation, "org-1");
3056            let invite2 = create_test_invite(recipient, EntityType::Group, "group-1");
3057            invite1_id = invite1.id.clone();
3058            invite2_id = invite2.id.clone();
3059
3060            service
3061                .store_invite_in_crdt(&invite1)
3062                .await
3063                .expect("store 1");
3064            service
3065                .store_invite_in_crdt(&invite2)
3066                .await
3067                .expect("store 2");
3068
3069            // Also store in recipient index
3070            service
3071                .store_recipient_index_in_crdt(recipient, &[invite1_id.clone(), invite2_id.clone()])
3072                .await
3073                .expect("store index");
3074        }
3075
3076        // Second instance - verify index persisted
3077        {
3078            let crdt_manager = Arc::new(
3079                CrdtManager::new(temp_dir.path())
3080                    .await
3081                    .expect("failed to create CrdtManager"),
3082            );
3083            let entity_service = create_test_entity_service(crdt_manager.clone());
3084            let service = InviteService::new(crdt_manager, entity_service);
3085
3086            let invite_ids = service
3087                .load_recipient_index_from_crdt(recipient)
3088                .await
3089                .expect("load index");
3090
3091            assert_eq!(invite_ids.len(), 2);
3092            assert!(invite_ids.contains(&invite1_id));
3093            assert!(invite_ids.contains(&invite2_id));
3094        }
3095    }
3096}