Skip to main content

koprogo_api/application/use_cases/
user_use_cases.rs

1use crate::application::error::AppError;
2use crate::application::ports::{UserRepository, UserRoleRepository};
3use crate::domain::entities::{User, UserRole, UserRoleAssignment};
4use chrono::{DateTime, Utc};
5use serde::Serialize;
6use std::collections::HashMap;
7use std::sync::Arc;
8use uuid::Uuid;
9
10#[derive(Serialize, Clone)]
11pub struct RoleResponse {
12    pub id: String,
13    pub role: String,
14    pub organization_id: Option<String>,
15    pub is_primary: bool,
16}
17
18#[derive(Serialize, Clone)]
19pub struct UserResponse {
20    pub id: String,
21    pub email: String,
22    pub first_name: String,
23    pub last_name: String,
24    pub role: String,
25    pub organization_id: Option<String>,
26    pub is_active: bool,
27    pub created_at: DateTime<Utc>,
28    pub roles: Vec<RoleResponse>,
29    pub active_role: Option<RoleResponse>,
30}
31
32pub struct UserUseCases {
33    user_repo: Arc<dyn UserRepository>,
34    role_repo: Arc<dyn UserRoleRepository>,
35}
36
37impl UserUseCases {
38    pub fn new(user_repo: Arc<dyn UserRepository>, role_repo: Arc<dyn UserRoleRepository>) -> Self {
39        Self {
40            user_repo,
41            role_repo,
42        }
43    }
44
45    fn to_role_response(a: &UserRoleAssignment) -> RoleResponse {
46        RoleResponse {
47            id: a.id.to_string(),
48            role: a.role.to_string(),
49            organization_id: a.organization_id.map(|id| id.to_string()),
50            is_primary: a.is_primary,
51        }
52    }
53
54    fn fallback_role(role: &str, organization_id: Option<Uuid>) -> RoleResponse {
55        RoleResponse {
56            id: Uuid::new_v4().to_string(),
57            role: role.to_string(),
58            organization_id: organization_id.map(|id| id.to_string()),
59            is_primary: true,
60        }
61    }
62
63    fn ensure_primary(roles: &mut [RoleResponse]) {
64        if roles.is_empty() {
65            return;
66        }
67        if roles.iter().filter(|r| r.is_primary).count() == 0 {
68            roles[0].is_primary = true;
69        }
70        roles.sort_by_key(|r| std::cmp::Reverse(r.is_primary));
71    }
72
73    fn build_response(user: User, assignments: Vec<UserRoleAssignment>) -> UserResponse {
74        let mut roles: Vec<RoleResponse> = if assignments.is_empty() {
75            vec![Self::fallback_role(
76                &user.role.to_string(),
77                user.organization_id,
78            )]
79        } else {
80            assignments.iter().map(Self::to_role_response).collect()
81        };
82
83        Self::ensure_primary(&mut roles);
84        let active_role = roles
85            .iter()
86            .find(|r| r.is_primary)
87            .cloned()
88            .or_else(|| roles.first().cloned());
89
90        UserResponse {
91            id: user.id.to_string(),
92            email: user.email.clone(),
93            first_name: user.first_name.clone(),
94            last_name: user.last_name.clone(),
95            role: active_role
96                .as_ref()
97                .map(|r| r.role.clone())
98                .unwrap_or_else(|| user.role.to_string()),
99            organization_id: active_role
100                .as_ref()
101                .and_then(|r| r.organization_id.clone())
102                .or_else(|| user.organization_id.map(|id| id.to_string())),
103            is_active: user.is_active,
104            created_at: user.created_at,
105            roles,
106            active_role,
107        }
108    }
109
110    /// Look up a user's display name by id (first_name + last_name).
111    /// Returns None if the user does not exist.
112    pub async fn find_display_name(&self, id: Uuid) -> Result<Option<String>, String> {
113        Ok(self.user_repo.find_by_id(id).await?.map(|u| {
114            format!("{} {}", u.first_name, u.last_name)
115                .trim()
116                .to_string()
117        }))
118    }
119
120    /// List all users with their roles.
121    pub async fn list_all(&self) -> Result<Vec<UserResponse>, String> {
122        let users = self.user_repo.find_all().await?;
123        let user_ids: Vec<Uuid> = users.iter().map(|u| u.id).collect();
124        let mut roles_map: HashMap<Uuid, Vec<UserRoleAssignment>> =
125            self.role_repo.list_for_users(&user_ids).await?;
126
127        Ok(users
128            .into_iter()
129            .map(|user| {
130                let assignments = roles_map.remove(&user.id).unwrap_or_default();
131                Self::build_response(user, assignments)
132            })
133            .collect())
134    }
135
136    /// Une page d'utilisateurs avec leurs rôles, et le total qui va avec.
137    ///
138    /// Le total n'est pas un ornement : sans lui, l'appelant reçoit un
139    /// fragment sans savoir la taille du tout, et c'est exactement ainsi
140    /// qu'une liste tronquée passe pour complète.
141    ///
142    /// `list_all` reste pour les appelants internes qui ont réellement besoin
143    /// de tout le monde. Ce qui n'en avait pas besoin, c'est l'écran
144    /// d'administration : il recevait 4 120 lignes pour en afficher vingt.
145    pub async fn list_page(
146        &self,
147        recherche: Option<String>,
148        role: Option<String>,
149        limit: i64,
150        offset: i64,
151    ) -> Result<(Vec<UserResponse>, i64), String> {
152        let users = self
153            .user_repo
154            .find_page(recherche.clone(), role.clone(), limit, offset)
155            .await?;
156        let total = self.user_repo.count_matching(recherche, role).await?;
157
158        // Les rôles sont chargés pour la PAGE seulement. `list_all` les
159        // chargeait pour la table entière, ce qui doublait le coût du défaut.
160        let user_ids: Vec<Uuid> = users.iter().map(|u| u.id).collect();
161        let mut roles_map: HashMap<Uuid, Vec<UserRoleAssignment>> =
162            self.role_repo.list_for_users(&user_ids).await?;
163
164        let page = users
165            .into_iter()
166            .map(|user| {
167                let assignments = roles_map.remove(&user.id).unwrap_or_default();
168                Self::build_response(user, assignments)
169            })
170            .collect();
171
172        Ok((page, total))
173    }
174
175    /// List users belonging to a given organization, with their roles.
176    ///
177    /// Authorization (syndic/accountant own org, superadmin any org) is
178    /// enforced upstream by the handler via `AuthenticatedUser::verify_org_access`.
179    pub async fn list_by_organization(
180        &self,
181        organization_id: Uuid,
182    ) -> Result<Vec<UserResponse>, String> {
183        let users = self.user_repo.find_by_organization(organization_id).await?;
184        let user_ids: Vec<Uuid> = users.iter().map(|u| u.id).collect();
185        let mut roles_map: HashMap<Uuid, Vec<UserRoleAssignment>> =
186            self.role_repo.list_for_users(&user_ids).await?;
187
188        Ok(users
189            .into_iter()
190            .map(|user| {
191                let assignments = roles_map.remove(&user.id).unwrap_or_default();
192                Self::build_response(user, assignments)
193            })
194            .collect())
195    }
196
197    /// Create a new user with role assignments.
198    /// Returns `Err("email_exists")` on duplicate email.
199    pub async fn create(
200        &self,
201        email: String,
202        password_hash: String,
203        first_name: String,
204        last_name: String,
205        primary_role: UserRole,
206        primary_org: Option<Uuid>,
207        role_assignments: Vec<UserRoleAssignment>,
208    ) -> Result<UserResponse, String> {
209        let user = User::new(
210            email,
211            password_hash,
212            first_name,
213            last_name,
214            primary_role,
215            primary_org,
216        )?;
217        let created = self.user_repo.create(&user).await?;
218
219        // Build assignments with the real user_id
220        let assignments: Vec<UserRoleAssignment> = role_assignments
221            .into_iter()
222            .map(|mut a| {
223                a.user_id = created.id;
224                a
225            })
226            .collect();
227        self.role_repo.replace_all(created.id, &assignments).await?;
228
229        let final_roles = self.role_repo.list_for_user(created.id).await?;
230        Ok(Self::build_response(created, final_roles))
231    }
232
233    /// Update an existing user. Returns `None` if not found.
234    /// Returns `Err("email_exists")` on duplicate email.
235    pub async fn update(
236        &self,
237        id: Uuid,
238        email: String,
239        first_name: String,
240        last_name: String,
241        primary_role: UserRole,
242        primary_org: Option<Uuid>,
243        password_hash: Option<String>,
244        role_assignments: Vec<UserRoleAssignment>,
245    ) -> Result<Option<UserResponse>, String> {
246        let mut user = match self.user_repo.find_by_id(id).await? {
247            Some(u) => u,
248            None => return Ok(None),
249        };
250
251        user.email = email.trim().to_lowercase();
252        user.first_name = first_name.trim().to_string();
253        user.last_name = last_name.trim().to_string();
254        user.role = primary_role;
255        user.organization_id = primary_org;
256        user.updated_at = Utc::now();
257
258        if let Some(pw) = password_hash {
259            self.user_repo.update_password(id, &pw).await?;
260        }
261
262        self.user_repo.update(&user).await?;
263
264        let assignments: Vec<UserRoleAssignment> = role_assignments
265            .into_iter()
266            .map(|mut a| {
267                a.user_id = id;
268                a
269            })
270            .collect();
271        self.role_repo.replace_all(id, &assignments).await?;
272
273        let final_roles = self.role_repo.list_for_user(id).await?;
274        Ok(Some(Self::build_response(user, final_roles)))
275    }
276
277    /// Activate a user. Returns `None` if not found.
278    pub async fn activate(&self, id: Uuid) -> Result<Option<UserResponse>, String> {
279        let user = match self.user_repo.activate(id).await? {
280            Some(u) => u,
281            None => return Ok(None),
282        };
283        let roles = self.role_repo.list_for_user(id).await?;
284        Ok(Some(Self::build_response(user, roles)))
285    }
286
287    /// Deactivate a user. Returns `None` if not found.
288    pub async fn deactivate(&self, id: Uuid) -> Result<Option<UserResponse>, String> {
289        let user = match self.user_repo.deactivate(id).await? {
290            Some(u) => u,
291            None => return Ok(None),
292        };
293        let roles = self.role_repo.list_for_user(id).await?;
294        Ok(Some(Self::build_response(user, roles)))
295    }
296
297    /// Delete a user. Returns `false` if not found.
298    pub async fn delete(&self, id: Uuid) -> Result<bool, String> {
299        self.user_repo.delete(id).await
300    }
301
302    /// Verify a user exists and holds the given role.
303    /// Returns `Err("User not found")` or `Err("User must have role '...' ...")`.
304    pub async fn validate_user_has_role(&self, user_id: Uuid, role: &str) -> Result<(), String> {
305        self.user_repo
306            .find_by_id(user_id)
307            .await?
308            .ok_or_else(|| "User not found".to_string())?;
309
310        let roles = self.role_repo.list_for_user(user_id).await?;
311        if !roles.iter().any(|r| r.role.to_string() == role) {
312            return Err(format!(
313                "User must have role '{}' to be linked to an owner entity",
314                role
315            ));
316        }
317        Ok(())
318    }
319
320    // ======================================================================
321    // Story B0bis — CRUD REST sur user_role_assignments (gap Story 3.1).
322    //
323    // Story 3.1 a livré l'entité + repo + helpers RBAC mais n'a JAMAIS exposé
324    // un endpoint pour assigner / lister / révoquer un sous-rôle. Cela
325    // bloquait toute UI d'administration ainsi que la Story B1 du Phase B FE
326    // (`RoleAssignmentForm` / `RoleAssignmentList`).
327    //
328    // Ces 3 méthodes sont **AppError-typées** (cf. CRITICAL.md §4) et
329    // appliquent les invariants :
330    //   - target user existant ;
331    //   - role parsable (whitelist UserRole::from_str) ;
332    //   - valid_until > now() si Some ;
333    //   - aucune double-attribution active sur le tuple
334    //     (user_id, role, organization_id).
335    // ======================================================================
336
337    /// Assigne un sous-rôle à un user existant.
338    ///
339    /// L'autorisation (superadmin / syndic-sur-son-org) est vérifiée AMONT par
340    /// le handler — ici on applique uniquement les invariants métier.
341    ///
342    /// # Errors
343    ///
344    /// - `AppError::NotFound` si `target_user_id` n'existe pas.
345    /// - `AppError::Validation` si `valid_until` n'est pas dans le futur, ou
346    ///   si un user tente de s'auto-attribuer un rôle à privilèges élevés
347    ///   (cohérent invariants MagicLink / Mandate §3.2 §3.4).
348    /// - `AppError::RoleAlreadyAssigned` si une assignment active existe déjà
349    ///   pour le tuple `(user_id, role, organization_id)`.
350    pub async fn assign_role(
351        &self,
352        target_user_id: Uuid,
353        role: UserRole,
354        organization_id: Option<Uuid>,
355        valid_until: Option<DateTime<Utc>>,
356        granted_by_user_id: Uuid,
357    ) -> Result<UserRoleAssignment, AppError> {
358        // --- @edge : valid_until doit être strictement futur si fourni -----
359        if let Some(t) = valid_until {
360            if t <= Utc::now() {
361                return Err(AppError::Validation(
362                    "valid_until must be strictly in the future".to_string(),
363                ));
364            }
365        }
366
367        // --- @security : auto-attribution interdite pour rôles à privilèges
368        //     élevés (superadmin / syndic). Cohérent avec l'invariant
369        //     subject!=issuer des MagicLink / Mandate / RoleDelegation.
370        let high_privilege = matches!(role, UserRole::SuperAdmin | UserRole::Syndic);
371        if high_privilege && granted_by_user_id == target_user_id {
372            return Err(AppError::Validation(
373                "A user cannot self-grant a high-privilege role".to_string(),
374            ));
375        }
376
377        // --- target user doit exister -------------------------------------
378        let target = self
379            .user_repo
380            .find_by_id(target_user_id)
381            .await
382            .map_err(AppError::from)?
383            .ok_or_else(|| AppError::NotFound(format!("User {} not found", target_user_id)))?;
384
385        // --- @negative : double-attribution -------------------------------
386        let existing = self
387            .role_repo
388            .list_for_user(target_user_id)
389            .await
390            .map_err(AppError::from)?;
391        let already = existing.iter().any(|a| {
392            a.role == role && a.organization_id == organization_id && a.is_currently_active()
393        });
394        if already {
395            return Err(AppError::RoleAlreadyAssigned {
396                user_id: target_user_id,
397                role: role.to_string(),
398            });
399        }
400
401        // --- Build assignment (delegated if `valid_until` is Some) --------
402        let is_first = existing.is_empty();
403        let assignment = match valid_until {
404            Some(t) => UserRoleAssignment::new_delegated(
405                target_user_id,
406                role,
407                organization_id,
408                t,
409                granted_by_user_id,
410            ),
411            None => UserRoleAssignment::new(target_user_id, role, organization_id, is_first),
412        };
413
414        let saved = self
415            .role_repo
416            .create(&assignment)
417            .await
418            .map_err(AppError::from)?;
419        // Silence the "unused" lint on the user we fetched to enforce existence.
420        let _ = target.id;
421        Ok(saved)
422    }
423
424    /// Liste toutes les assignments d'un user (actives + expirées) afin que
425    /// le FE puisse afficher l'historique.
426    ///
427    /// L'autorisation (superadmin / syndic-sur-son-org / self) est vérifiée
428    /// AMONT par le handler.
429    pub async fn list_assignments_for_user(
430        &self,
431        user_id: Uuid,
432    ) -> Result<Vec<UserRoleAssignment>, AppError> {
433        self.role_repo
434            .list_for_user(user_id)
435            .await
436            .map_err(AppError::from)
437    }
438
439    /// Révoque (= supprime) une assignment.
440    ///
441    /// L'autorisation est vérifiée AMONT par le handler. Renvoie
442    /// `AppError::NotFound` si l'assignment n'existe pas.
443    pub async fn revoke_assignment(&self, assignment_id: Uuid) -> Result<(), AppError> {
444        let existing = self
445            .role_repo
446            .find_by_id(assignment_id)
447            .await
448            .map_err(AppError::from)?
449            .ok_or_else(|| {
450                AppError::NotFound(format!("Role assignment {} not found", assignment_id))
451            })?;
452        let _ = existing.id;
453        let deleted = self
454            .role_repo
455            .delete_by_id(assignment_id)
456            .await
457            .map_err(AppError::from)?;
458        if !deleted {
459            // Race condition : found-then-deleted-by-someone-else.
460            return Err(AppError::NotFound(format!(
461                "Role assignment {} not found",
462                assignment_id
463            )));
464        }
465        Ok(())
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::application::ports::user_repository::MockUserRepo;
473    use crate::application::ports::user_role_repository::MockUserRoleRepo;
474    use chrono::Utc;
475
476    fn make_user(id: Uuid) -> User {
477        User {
478            id,
479            email: "alice@example.com".to_string(),
480            password_hash: "hash".to_string(),
481            first_name: "Alice".to_string(),
482            last_name: "Smith".to_string(),
483            role: UserRole::Syndic,
484            organization_id: Some(Uuid::new_v4()),
485            is_active: true,
486            processing_restricted: false,
487            processing_restricted_at: None,
488            marketing_opt_out: false,
489            marketing_opt_out_at: None,
490            created_at: Utc::now(),
491            updated_at: Utc::now(),
492        }
493    }
494
495    #[tokio::test]
496    async fn test_list_all_returns_users_with_roles() {
497        let user_id = Uuid::new_v4();
498        let user = make_user(user_id);
499
500        let mut mock_user = MockUserRepo::new();
501        mock_user
502            .expect_find_all()
503            .returning(move || Ok(vec![user.clone()]));
504
505        let mut mock_role = MockUserRoleRepo::new();
506        mock_role
507            .expect_list_for_users()
508            .returning(|_| Ok(std::collections::HashMap::new()));
509
510        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
511        let result = uc.list_all().await.unwrap();
512        assert_eq!(result.len(), 1);
513        assert_eq!(result[0].email, "alice@example.com");
514    }
515
516    // ── `list_page` — les quatre classes (#953) ───────────────────────────
517    //
518    // Ce que ces tests gardent n'est pas « la pagination marche » : c'est que
519    // les DEUX filtres voyagent jusqu'au dépôt, et que le total en porte les
520    // mêmes. Un total calculé sans le filtre annoncerait « 50 sur 4 120 »
521    // alors qu'il en existe douze, et l'écran mentirait à chaque rendu.
522
523    #[tokio::test]
524    async fn happy_list_page_transmet_recherche_role_et_bornes() {
525        let user = make_user(Uuid::new_v4());
526
527        let mut mock_user = MockUserRepo::new();
528        mock_user
529            .expect_find_page()
530            .withf(|recherche, role, limit, offset| {
531                recherche.as_deref() == Some("ali")
532                    && role.as_deref() == Some("syndic")
533                    && *limit == 50
534                    && *offset == 100
535            })
536            .returning(move |_, _, _, _| Ok(vec![user.clone()]));
537        mock_user
538            .expect_count_matching()
539            .withf(|recherche, role| {
540                recherche.as_deref() == Some("ali") && role.as_deref() == Some("syndic")
541            })
542            .returning(|_, _| Ok(4120));
543
544        let mut mock_role = MockUserRoleRepo::new();
545        mock_role
546            .expect_list_for_users()
547            .returning(|_| Ok(std::collections::HashMap::new()));
548
549        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
550        let (page, total) = uc
551            .list_page(Some("ali".to_string()), Some("syndic".to_string()), 50, 100)
552            .await
553            .unwrap();
554
555        assert_eq!(page.len(), 1);
556        assert_eq!(total, 4120, "le total vient du dépôt, pas de la page");
557    }
558
559    #[tokio::test]
560    async fn negative_list_page_remonte_lerreur_du_comptage() {
561        // Un comptage en échec ne doit pas rendre une page « complète ».
562        // Sans total fiable, l'appelant ne peut pas savoir qu'il ne voit
563        // qu'un fragment — et c'est exactement le défaut que #953 corrige.
564        let mut mock_user = MockUserRepo::new();
565        mock_user
566            .expect_find_page()
567            .returning(|_, _, _, _| Ok(Vec::new()));
568        mock_user
569            .expect_count_matching()
570            .returning(|_, _| Err("base injoignable".to_string()));
571
572        let mock_role = MockUserRoleRepo::new();
573        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
574        let resultat = uc.list_page(None, None, 20, 0).await;
575
576        assert!(resultat.is_err(), "l'erreur de comptage doit remonter");
577    }
578
579    #[tokio::test]
580    async fn edge_list_page_sans_filtre_passe_none_au_depot() {
581        // Une recherche vide n'est pas une recherche : elle ne doit pas
582        // devenir un `%%` qui filtrerait sur rien tout en coûtant un balayage.
583        let mut mock_user = MockUserRepo::new();
584        mock_user
585            .expect_find_page()
586            .withf(|recherche, role, _, _| recherche.is_none() && role.is_none())
587            .returning(|_, _, _, _| Ok(Vec::new()));
588        mock_user
589            .expect_count_matching()
590            .withf(|recherche, role| recherche.is_none() && role.is_none())
591            .returning(|_, _| Ok(0));
592
593        let mut mock_role = MockUserRoleRepo::new();
594        mock_role
595            .expect_list_for_users()
596            .returning(|_| Ok(std::collections::HashMap::new()));
597
598        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
599        let (page, total) = uc.list_page(None, None, 20, 0).await.unwrap();
600
601        assert!(page.is_empty());
602        assert_eq!(total, 0);
603    }
604
605    #[tokio::test]
606    async fn security_list_page_ne_charge_les_roles_que_de_la_page() {
607        // Les rôles sont chargés pour les identifiants de la PAGE, jamais
608        // pour la table. `list_all` les chargeait pour les 4 120 lignes, ce
609        // qui doublait le coût du défaut mesuré en #953.
610        let un = make_user(Uuid::new_v4());
611        let deux = make_user(Uuid::new_v4());
612        let attendus = [un.id, deux.id];
613
614        let mut mock_user = MockUserRepo::new();
615        mock_user
616            .expect_find_page()
617            .returning(move |_, _, _, _| Ok(vec![un.clone(), deux.clone()]));
618        mock_user.expect_count_matching().returning(|_, _| Ok(4120));
619
620        let mut mock_role = MockUserRoleRepo::new();
621        mock_role
622            .expect_list_for_users()
623            .withf(move |ids: &[Uuid]| ids.len() == 2 && ids.iter().all(|i| attendus.contains(i)))
624            .returning(|_| Ok(std::collections::HashMap::new()));
625
626        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
627        let (page, _) = uc.list_page(None, None, 50, 0).await.unwrap();
628
629        assert_eq!(page.len(), 2);
630    }
631
632    #[tokio::test]
633    async fn test_activate_not_found() {
634        let mut mock_user = MockUserRepo::new();
635        mock_user.expect_activate().returning(|_| Ok(None));
636
637        let mock_role = MockUserRoleRepo::new();
638        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
639        let result = uc.activate(Uuid::new_v4()).await.unwrap();
640        assert!(result.is_none());
641    }
642
643    #[tokio::test]
644    async fn test_delete_delegates_to_repo() {
645        let mut mock_user = MockUserRepo::new();
646        mock_user.expect_delete().returning(|_| Ok(true));
647
648        let mock_role = MockUserRoleRepo::new();
649        let uc = UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role));
650        let result = uc.delete(Uuid::new_v4()).await.unwrap();
651        assert!(result);
652    }
653
654    // ======================================================================
655    // Story B0bis — assign_role / list_assignments_for_user / revoke
656    // — taxonomie 4 catégories (CRITICAL.md §3).
657    // ======================================================================
658
659    fn make_uc_with_mocks(mock_user: MockUserRepo, mock_role: MockUserRoleRepo) -> UserUseCases {
660        UserUseCases::new(Arc::new(mock_user), Arc::new(mock_role))
661    }
662
663    // ---- @happy ---------------------------------------------------------
664
665    #[tokio::test]
666    async fn happy_assign_role_creates_native_assignment() {
667        let target = Uuid::new_v4();
668        let granted_by = Uuid::new_v4();
669        let user = make_user(target);
670
671        let mut mock_user = MockUserRepo::new();
672        let u = user.clone();
673        mock_user
674            .expect_find_by_id()
675            .returning(move |_| Ok(Some(u.clone())));
676
677        let mut mock_role = MockUserRoleRepo::new();
678        mock_role.expect_list_for_user().returning(|_| Ok(vec![]));
679        mock_role
680            .expect_create()
681            .returning(|a: &UserRoleAssignment| Ok(a.clone()));
682
683        let uc = make_uc_with_mocks(mock_user, mock_role);
684        let saved = uc
685            .assign_role(target, UserRole::AccountantEncodeur, None, None, granted_by)
686            .await
687            .unwrap();
688        assert_eq!(saved.user_id, target);
689        assert_eq!(saved.role, UserRole::AccountantEncodeur);
690        assert!(saved.valid_until.is_none(), "native assignment");
691    }
692
693    #[tokio::test]
694    async fn happy_list_assignments_returns_repo_rows() {
695        let user_id = Uuid::new_v4();
696        let a = UserRoleAssignment::new(user_id, UserRole::Owner, None, true);
697
698        let mock_user = MockUserRepo::new();
699        let mut mock_role = MockUserRoleRepo::new();
700        let row = a.clone();
701        mock_role
702            .expect_list_for_user()
703            .returning(move |_| Ok(vec![row.clone()]));
704
705        let uc = make_uc_with_mocks(mock_user, mock_role);
706        let rows = uc.list_assignments_for_user(user_id).await.unwrap();
707        assert_eq!(rows.len(), 1);
708        assert_eq!(rows[0].id, a.id);
709    }
710
711    #[tokio::test]
712    async fn happy_revoke_assignment_deletes_existing() {
713        let user_id = Uuid::new_v4();
714        let a = UserRoleAssignment::new(user_id, UserRole::Owner, None, true);
715        let a_id = a.id;
716
717        let mock_user = MockUserRepo::new();
718        let mut mock_role = MockUserRoleRepo::new();
719        let row = a.clone();
720        mock_role
721            .expect_find_by_id()
722            .returning(move |_| Ok(Some(row.clone())));
723        mock_role.expect_delete_by_id().returning(|_| Ok(true));
724
725        let uc = make_uc_with_mocks(mock_user, mock_role);
726        uc.revoke_assignment(a_id).await.unwrap();
727    }
728
729    // ---- @edge ----------------------------------------------------------
730
731    #[tokio::test]
732    async fn edge_assign_role_with_future_valid_until_creates_delegated() {
733        let target = Uuid::new_v4();
734        let granted_by = Uuid::new_v4();
735        let user = make_user(target);
736
737        let mut mock_user = MockUserRepo::new();
738        let u = user.clone();
739        mock_user
740            .expect_find_by_id()
741            .returning(move |_| Ok(Some(u.clone())));
742
743        let mut mock_role = MockUserRoleRepo::new();
744        mock_role.expect_list_for_user().returning(|_| Ok(vec![]));
745        mock_role
746            .expect_create()
747            .returning(|a: &UserRoleAssignment| Ok(a.clone()));
748
749        let uc = make_uc_with_mocks(mock_user, mock_role);
750        let valid_until = Utc::now() + chrono::Duration::seconds(60);
751        let saved = uc
752            .assign_role(
753                target,
754                UserRole::Lawyer,
755                None,
756                Some(valid_until),
757                granted_by,
758            )
759            .await
760            .unwrap();
761        assert!(saved.is_delegated());
762        assert_eq!(saved.valid_until, Some(valid_until));
763    }
764
765    #[tokio::test]
766    async fn edge_assign_role_with_past_valid_until_is_rejected() {
767        let target = Uuid::new_v4();
768        let granted_by = Uuid::new_v4();
769
770        let mock_user = MockUserRepo::new();
771        let mock_role = MockUserRoleRepo::new();
772
773        let uc = make_uc_with_mocks(mock_user, mock_role);
774        let valid_until = Utc::now() - chrono::Duration::seconds(1);
775        let err = uc
776            .assign_role(target, UserRole::Owner, None, Some(valid_until), granted_by)
777            .await
778            .unwrap_err();
779        assert!(matches!(err, AppError::Validation(_)));
780    }
781
782    // ---- @security ------------------------------------------------------
783
784    #[tokio::test]
785    async fn security_assign_high_privilege_role_to_self_is_rejected() {
786        // INV: a user cannot self-grant Syndic / SuperAdmin (cohérent §3.2 §3.4).
787        let same = Uuid::new_v4();
788
789        let mock_user = MockUserRepo::new();
790        let mock_role = MockUserRoleRepo::new();
791
792        let uc = make_uc_with_mocks(mock_user, mock_role);
793        let err = uc
794            .assign_role(same, UserRole::Syndic, None, None, same)
795            .await
796            .unwrap_err();
797        assert!(matches!(err, AppError::Validation(_)));
798    }
799
800    #[tokio::test]
801    async fn security_assign_non_privileged_self_role_is_allowed() {
802        // Self-assign d'un rôle non-privilégié (community.moderator, …) reste
803        // OK — pas de loophole, c'est juste qu'on ne traite pas tous les
804        // rôles comme égaux en risque.
805        let same = Uuid::new_v4();
806        let user = make_user(same);
807
808        let mut mock_user = MockUserRepo::new();
809        let u = user.clone();
810        mock_user
811            .expect_find_by_id()
812            .returning(move |_| Ok(Some(u.clone())));
813
814        let mut mock_role = MockUserRoleRepo::new();
815        mock_role.expect_list_for_user().returning(|_| Ok(vec![]));
816        mock_role
817            .expect_create()
818            .returning(|a: &UserRoleAssignment| Ok(a.clone()));
819
820        let uc = make_uc_with_mocks(mock_user, mock_role);
821        let saved = uc
822            .assign_role(same, UserRole::CommunityModerator, None, None, same)
823            .await
824            .unwrap();
825        assert_eq!(saved.role, UserRole::CommunityModerator);
826    }
827
828    // ---- @negative ------------------------------------------------------
829
830    #[tokio::test]
831    async fn negative_assign_role_to_unknown_user_returns_not_found() {
832        let target = Uuid::new_v4();
833        let granted_by = Uuid::new_v4();
834
835        let mut mock_user = MockUserRepo::new();
836        mock_user.expect_find_by_id().returning(|_| Ok(None));
837
838        let mock_role = MockUserRoleRepo::new();
839
840        let uc = make_uc_with_mocks(mock_user, mock_role);
841        let err = uc
842            .assign_role(target, UserRole::Owner, None, None, granted_by)
843            .await
844            .unwrap_err();
845        assert!(matches!(err, AppError::NotFound(_)));
846    }
847
848    #[tokio::test]
849    async fn negative_assign_role_double_returns_conflict() {
850        let target = Uuid::new_v4();
851        let granted_by = Uuid::new_v4();
852        let user = make_user(target);
853        // Pre-existing native assignment on the same (role, org).
854        let existing = UserRoleAssignment::new(target, UserRole::Owner, None, true);
855
856        let mut mock_user = MockUserRepo::new();
857        let u = user.clone();
858        mock_user
859            .expect_find_by_id()
860            .returning(move |_| Ok(Some(u.clone())));
861
862        let mut mock_role = MockUserRoleRepo::new();
863        let row = existing.clone();
864        mock_role
865            .expect_list_for_user()
866            .returning(move |_| Ok(vec![row.clone()]));
867
868        let uc = make_uc_with_mocks(mock_user, mock_role);
869        let err = uc
870            .assign_role(target, UserRole::Owner, None, None, granted_by)
871            .await
872            .unwrap_err();
873        assert!(
874            matches!(err, AppError::RoleAlreadyAssigned { .. }),
875            "expected RoleAlreadyAssigned, got {:?}",
876            err
877        );
878    }
879
880    #[tokio::test]
881    async fn negative_revoke_unknown_assignment_returns_not_found() {
882        let mock_user = MockUserRepo::new();
883        let mut mock_role = MockUserRoleRepo::new();
884        mock_role.expect_find_by_id().returning(|_| Ok(None));
885
886        let uc = make_uc_with_mocks(mock_user, mock_role);
887        let err = uc.revoke_assignment(Uuid::new_v4()).await.unwrap_err();
888        assert!(matches!(err, AppError::NotFound(_)));
889    }
890}