koprogo_api/domain/plateforme/
portfolio.rs1use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use uuid::Uuid;
26
27#[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#[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 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 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#[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#[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#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[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 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 #[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 #[test]
254 fn security_owner_user_id_is_required_to_be_explicit() {
255 let _: fn(Uuid, String, Option<String>) -> Result<Portfolio, PortfolioError> =
258 Portfolio::new;
259 }
260
261 #[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 assert_eq!(p.name, "Valid");
302 }
303}