Skip to main content

koprogo_api/domain/plateforme/
portfolio.rs

1//! `Portfolio` — portefeuille immeubles d'un utilisateur (favoris/équipe).
2//!
3//! Story 2.1 — Slice 2 Refonte UX multi-rôle ACP.
4//! Source : `docs/maury/refonte-ux-multi-role-acp/architecture.md` §2.5 + ADR-0011.
5//!
6//! Un portefeuille (`Portfolio`) regroupe N immeubles `(building_id, is_favorite)`
7//! pour un propriétaire `owner_user_id`. Il peut être partagé en lecture
8//! (option `can_edit`) avec d'autres `User` (typiquement équipe d'un cabinet
9//! syndic).
10//!
11//! # Invariants
12//!
13//! - `name` non vide après trim, longueur ∈ [2, 120] caractères.
14//! - `description` optionnel (longueur ≤ 1000 si présent, post-trim).
15//!
16//! # Hexagonal
17//!
18//! Aucune dépendance `sqlx` / `actix_web`. Les erreurs domaine retournent
19//! `PortfolioError` (enum), mappé vers `AppError::Validation` côté
20//! application (`application/error.rs`, pattern WP-A* #433).
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use uuid::Uuid;
26
27/// Erreurs métier produites par le domaine `Portfolio`.
28///
29/// Mappées vers `AppError::Validation` (HTTP 400/422).
30#[derive(Error, Debug, Clone, PartialEq, Eq)]
31pub enum PortfolioError {
32    #[error("Portfolio name cannot be empty")]
33    NameEmpty,
34    #[error("Portfolio name must be at least 2 characters long, got {0}")]
35    NameTooShort(usize),
36    #[error("Portfolio name must be at most 120 characters long, got {0}")]
37    NameTooLong(usize),
38    #[error("Portfolio description must be at most 1000 characters long, got {0}")]
39    DescriptionTooLong(usize),
40}
41
42/// Représente un portefeuille (Aggregate Root).
43///
44/// Cf. ADR-0011 (`docs/maury/refonte-ux-multi-role-acp/architecture.md`).
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
46pub struct Portfolio {
47    pub id: Uuid,
48    pub owner_user_id: Uuid,
49    pub name: String,
50    pub description: Option<String>,
51    pub created_at: DateTime<Utc>,
52    pub updated_at: DateTime<Utc>,
53}
54
55impl Portfolio {
56    /// Constructeur validé.
57    ///
58    /// Invariants vérifiés :
59    /// 1. `name.trim()` ∈ [2, 120] caractères.
60    /// 2. `description.trim()` ≤ 1000 caractères si présent.
61    pub fn new(
62        owner_user_id: Uuid,
63        name: String,
64        description: Option<String>,
65    ) -> Result<Self, PortfolioError> {
66        let name = name.trim().to_string();
67        if name.is_empty() {
68            return Err(PortfolioError::NameEmpty);
69        }
70        let name_len = name.chars().count();
71        if name_len < 2 {
72            return Err(PortfolioError::NameTooShort(name_len));
73        }
74        if name_len > 120 {
75            return Err(PortfolioError::NameTooLong(name_len));
76        }
77
78        let description = match description {
79            Some(s) => {
80                let trimmed = s.trim().to_string();
81                if trimmed.is_empty() {
82                    None
83                } else {
84                    let d_len = trimmed.chars().count();
85                    if d_len > 1000 {
86                        return Err(PortfolioError::DescriptionTooLong(d_len));
87                    }
88                    Some(trimmed)
89                }
90            }
91            None => None,
92        };
93
94        let now = Utc::now();
95        Ok(Self {
96            id: Uuid::new_v4(),
97            owner_user_id,
98            name,
99            description,
100            created_at: now,
101            updated_at: now,
102        })
103    }
104
105    /// Mise à jour `name` + `description` avec re-validation des invariants.
106    pub fn update_info(
107        &mut self,
108        name: String,
109        description: Option<String>,
110    ) -> Result<(), PortfolioError> {
111        let name = name.trim().to_string();
112        if name.is_empty() {
113            return Err(PortfolioError::NameEmpty);
114        }
115        let name_len = name.chars().count();
116        if name_len < 2 {
117            return Err(PortfolioError::NameTooShort(name_len));
118        }
119        if name_len > 120 {
120            return Err(PortfolioError::NameTooLong(name_len));
121        }
122        let description = match description {
123            Some(s) => {
124                let trimmed = s.trim().to_string();
125                if trimmed.is_empty() {
126                    None
127                } else {
128                    let d_len = trimmed.chars().count();
129                    if d_len > 1000 {
130                        return Err(PortfolioError::DescriptionTooLong(d_len));
131                    }
132                    Some(trimmed)
133                }
134            }
135            None => None,
136        };
137        self.name = name;
138        self.description = description;
139        self.updated_at = Utc::now();
140        Ok(())
141    }
142}
143
144/// Entité de liaison M:N — un building dans un portfolio.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
146pub struct PortfolioBuilding {
147    pub portfolio_id: Uuid,
148    pub building_id: Uuid,
149    pub is_favorite: bool,
150    pub added_at: DateTime<Utc>,
151}
152
153/// Entité de liaison — partage portfolio ↔ user.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
155pub struct PortfolioShare {
156    pub portfolio_id: Uuid,
157    pub shared_with_user_id: Uuid,
158    pub can_edit: bool,
159    pub shared_at: DateTime<Utc>,
160}
161
162// ============================================================================
163// Tests — taxonomie 4 catégories (CRITICAL.md règle #3, #427).
164// ============================================================================
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    // ----- @happy -------------------------------------------------------------
171
172    #[test]
173    fn happy_new_portfolio_minimal_succeeds() {
174        let user_id = Uuid::new_v4();
175        let p = Portfolio::new(user_id, "Mes immeubles favoris".to_string(), None)
176            .expect("valid portfolio");
177        assert_eq!(p.owner_user_id, user_id);
178        assert_eq!(p.name, "Mes immeubles favoris");
179        assert!(p.description.is_none());
180        assert_eq!(p.created_at, p.updated_at);
181    }
182
183    #[test]
184    fn happy_new_portfolio_with_description() {
185        let p = Portfolio::new(
186            Uuid::new_v4(),
187            "Cabinet B".to_string(),
188            Some("Immeubles du portefeuille du gestionnaire B".to_string()),
189        )
190        .unwrap();
191        assert_eq!(
192            p.description.as_deref(),
193            Some("Immeubles du portefeuille du gestionnaire B")
194        );
195    }
196
197    #[test]
198    fn happy_update_info_changes_name_and_touches_updated_at() {
199        let mut p = Portfolio::new(Uuid::new_v4(), "Old".to_string(), None).unwrap();
200        let original = p.updated_at;
201        // Sleep avoided: use_cases tests cover real time deltas — here we
202        // just check the value moved forward (>= original).
203        p.update_info("New".to_string(), Some("desc".to_string()))
204            .unwrap();
205        assert_eq!(p.name, "New");
206        assert_eq!(p.description.as_deref(), Some("desc"));
207        assert!(p.updated_at >= original);
208    }
209
210    // ----- @edge --------------------------------------------------------------
211
212    #[test]
213    fn edge_minimum_name_length_2_accepted() {
214        let p = Portfolio::new(Uuid::new_v4(), "Ab".to_string(), None);
215        assert!(p.is_ok());
216    }
217
218    #[test]
219    fn edge_max_name_length_120_accepted() {
220        let name = "A".repeat(120);
221        let p = Portfolio::new(Uuid::new_v4(), name.clone(), None);
222        assert!(p.is_ok());
223        assert_eq!(p.unwrap().name.chars().count(), 120);
224    }
225
226    #[test]
227    fn edge_name_trimmed_before_validation() {
228        let p =
229            Portfolio::new(Uuid::new_v4(), "   Trimmed Portfolio   ".to_string(), None).unwrap();
230        assert_eq!(p.name, "Trimmed Portfolio");
231    }
232
233    #[test]
234    fn edge_description_whitespace_only_becomes_none() {
235        let p =
236            Portfolio::new(Uuid::new_v4(), "Name".to_string(), Some("   ".to_string())).unwrap();
237        assert!(p.description.is_none());
238    }
239
240    #[test]
241    fn edge_description_max_1000_chars_accepted() {
242        let d = "x".repeat(1000);
243        let p = Portfolio::new(Uuid::new_v4(), "Name".to_string(), Some(d));
244        assert!(p.is_ok());
245    }
246
247    // ----- @security ----------------------------------------------------------
248
249    // L'agrégat lui-même ne porte pas la logique RBAC (qui vit dans les
250    // use-cases — `portfolio_use_cases.rs`). On s'assure cependant que
251    // l'invariant structurel : `owner_user_id` est REQUIS — pas de
252    // fallback "current user" implicite.
253    #[test]
254    fn security_owner_user_id_is_required_to_be_explicit() {
255        // Compile-time guarantee : la signature impose `Uuid`,
256        // pas de fallback "current user" implicite.
257        let _: fn(Uuid, String, Option<String>) -> Result<Portfolio, PortfolioError> =
258            Portfolio::new;
259    }
260
261    // ----- @negative ----------------------------------------------------------
262
263    #[test]
264    fn negative_empty_name_is_rejected() {
265        let err = Portfolio::new(Uuid::new_v4(), "".to_string(), None).unwrap_err();
266        assert_eq!(err, PortfolioError::NameEmpty);
267    }
268
269    #[test]
270    fn negative_whitespace_only_name_is_rejected_as_empty() {
271        let err = Portfolio::new(Uuid::new_v4(), "     ".to_string(), None).unwrap_err();
272        assert_eq!(err, PortfolioError::NameEmpty);
273    }
274
275    #[test]
276    fn negative_single_char_name_is_too_short() {
277        let err = Portfolio::new(Uuid::new_v4(), "A".to_string(), None).unwrap_err();
278        assert_eq!(err, PortfolioError::NameTooShort(1));
279    }
280
281    #[test]
282    fn negative_too_long_name_is_rejected() {
283        let name = "B".repeat(121);
284        let err = Portfolio::new(Uuid::new_v4(), name, None).unwrap_err();
285        assert_eq!(err, PortfolioError::NameTooLong(121));
286    }
287
288    #[test]
289    fn negative_too_long_description_is_rejected() {
290        let d = "x".repeat(1001);
291        let err = Portfolio::new(Uuid::new_v4(), "Name".to_string(), Some(d)).unwrap_err();
292        assert_eq!(err, PortfolioError::DescriptionTooLong(1001));
293    }
294
295    #[test]
296    fn negative_update_info_re_validates_invariants() {
297        let mut p = Portfolio::new(Uuid::new_v4(), "Valid".to_string(), None).unwrap();
298        let err = p.update_info("".to_string(), None).unwrap_err();
299        assert_eq!(err, PortfolioError::NameEmpty);
300        // Name unchanged because update failed.
301        assert_eq!(p.name, "Valid");
302    }
303}