1use crate::application::dto::{
23 AddBuildingDto, CreatePortfolioDto, PortfolioBuildingResponseDto, PortfolioResponseDto,
24 PortfolioShareResponseDto, SharePortfolioDto, UpdatePortfolioDto,
25};
26use crate::application::error::AppError;
27use crate::application::ports::{BuildingRepository, PortfolioRepository, UserRepository};
28use crate::domain::entities::{Portfolio, PortfolioShare};
29use std::sync::Arc;
30use uuid::Uuid;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct PortfolioCaller {
41 pub user_id: Uuid,
42}
43
44pub struct PortfolioUseCases {
45 repository: Arc<dyn PortfolioRepository>,
46 building_repository: Arc<dyn BuildingRepository>,
47 user_repository: Arc<dyn UserRepository>,
48}
49
50impl PortfolioUseCases {
51 pub fn new(
52 repository: Arc<dyn PortfolioRepository>,
53 building_repository: Arc<dyn BuildingRepository>,
54 user_repository: Arc<dyn UserRepository>,
55 ) -> Self {
56 Self {
57 repository,
58 building_repository,
59 user_repository,
60 }
61 }
62
63 pub async fn create_portfolio(
65 &self,
66 caller: &PortfolioCaller,
67 dto: CreatePortfolioDto,
68 ) -> Result<PortfolioResponseDto, AppError> {
69 let portfolio = Portfolio::new(caller.user_id, dto.name, dto.description)?;
70 let created = self.repository.create(&portfolio).await?;
71 Ok(Self::to_response(&created))
72 }
73
74 pub async fn get_portfolio(
76 &self,
77 caller: &PortfolioCaller,
78 id: Uuid,
79 ) -> Result<PortfolioResponseDto, AppError> {
80 let portfolio = self
81 .repository
82 .find_by_id(id)
83 .await?
84 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", id)))?;
85 self.assert_can_read(caller, &portfolio).await?;
86 Ok(Self::to_response(&portfolio))
87 }
88
89 pub async fn list_portfolios(
91 &self,
92 caller: &PortfolioCaller,
93 ) -> Result<Vec<PortfolioResponseDto>, AppError> {
94 let list = self.repository.list_for_user(caller.user_id).await?;
95 Ok(list.iter().map(Self::to_response).collect())
96 }
97
98 pub async fn update_portfolio(
100 &self,
101 caller: &PortfolioCaller,
102 id: Uuid,
103 dto: UpdatePortfolioDto,
104 ) -> Result<PortfolioResponseDto, AppError> {
105 let mut portfolio = self
106 .repository
107 .find_by_id(id)
108 .await?
109 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", id)))?;
110 self.assert_can_write(caller, &portfolio).await?;
111 portfolio.update_info(dto.name, dto.description)?;
112 let updated = self.repository.update(&portfolio).await?;
113 Ok(Self::to_response(&updated))
114 }
115
116 pub async fn delete_portfolio(
118 &self,
119 caller: &PortfolioCaller,
120 id: Uuid,
121 ) -> Result<(), AppError> {
122 let portfolio = self
123 .repository
124 .find_by_id(id)
125 .await?
126 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", id)))?;
127 self.assert_is_owner(caller, &portfolio)?;
128 self.repository.delete(id).await
129 }
130
131 pub async fn add_building(
133 &self,
134 caller: &PortfolioCaller,
135 portfolio_id: Uuid,
136 dto: AddBuildingDto,
137 ) -> Result<PortfolioBuildingResponseDto, AppError> {
138 let portfolio = self
139 .repository
140 .find_by_id(portfolio_id)
141 .await?
142 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
143 self.assert_can_write(caller, &portfolio).await?;
144
145 let building_id = Uuid::parse_str(&dto.building_id)
146 .map_err(|_| AppError::Validation("Invalid building_id format".to_string()))?;
147
148 let exists = self
152 .building_repository
153 .find_by_id(building_id)
154 .await
155 .map_err(AppError::from)?
156 .is_some();
157 if !exists {
158 return Err(AppError::NotFound(format!(
159 "Building {} not found",
160 building_id
161 )));
162 }
163
164 let entry = self
165 .repository
166 .add_building(portfolio_id, building_id, dto.is_favorite)
167 .await?;
168 Ok(PortfolioBuildingResponseDto {
169 portfolio_id: entry.portfolio_id.to_string(),
170 building_id: entry.building_id.to_string(),
171 is_favorite: entry.is_favorite,
172 })
173 }
174
175 pub async fn remove_building(
177 &self,
178 caller: &PortfolioCaller,
179 portfolio_id: Uuid,
180 building_id: Uuid,
181 ) -> Result<(), AppError> {
182 let portfolio = self
183 .repository
184 .find_by_id(portfolio_id)
185 .await?
186 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
187 self.assert_can_write(caller, &portfolio).await?;
188 self.repository
189 .remove_building(portfolio_id, building_id)
190 .await
191 }
192
193 pub async fn list_buildings(
197 &self,
198 caller: &PortfolioCaller,
199 portfolio_id: Uuid,
200 ) -> Result<Vec<PortfolioBuildingResponseDto>, AppError> {
201 let portfolio = self
202 .repository
203 .find_by_id(portfolio_id)
204 .await?
205 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
206 self.assert_can_read(caller, &portfolio).await?;
207 let entries = self.repository.list_buildings(portfolio_id).await?;
208 Ok(entries
209 .into_iter()
210 .map(|e| PortfolioBuildingResponseDto {
211 portfolio_id: e.portfolio_id.to_string(),
212 building_id: e.building_id.to_string(),
213 is_favorite: e.is_favorite,
214 })
215 .collect())
216 }
217
218 pub async fn share_with(
220 &self,
221 caller: &PortfolioCaller,
222 portfolio_id: Uuid,
223 dto: SharePortfolioDto,
224 ) -> Result<PortfolioShareResponseDto, AppError> {
225 let portfolio = self
226 .repository
227 .find_by_id(portfolio_id)
228 .await?
229 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
230 self.assert_is_owner(caller, &portfolio)?;
231
232 let user_id = Uuid::parse_str(&dto.shared_with_user_id)
233 .map_err(|_| AppError::Validation("Invalid shared_with_user_id format".to_string()))?;
234 let exists = self
236 .user_repository
237 .find_by_id(user_id)
238 .await
239 .map_err(AppError::from)?
240 .is_some();
241 if !exists {
242 return Err(AppError::NotFound(format!("User {} not found", user_id)));
243 }
244
245 let share = self
246 .repository
247 .share_with(portfolio_id, user_id, dto.can_edit)
248 .await?;
249 Ok(Self::share_to_response(&share))
250 }
251
252 pub async fn unshare(
254 &self,
255 caller: &PortfolioCaller,
256 portfolio_id: Uuid,
257 shared_with_user_id: Uuid,
258 ) -> Result<(), AppError> {
259 let portfolio = self
260 .repository
261 .find_by_id(portfolio_id)
262 .await?
263 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
264 self.assert_is_owner(caller, &portfolio)?;
265 self.repository
266 .unshare(portfolio_id, shared_with_user_id)
267 .await
268 }
269
270 pub async fn list_shares(
272 &self,
273 caller: &PortfolioCaller,
274 portfolio_id: Uuid,
275 ) -> Result<Vec<PortfolioShareResponseDto>, AppError> {
276 let portfolio = self
277 .repository
278 .find_by_id(portfolio_id)
279 .await?
280 .ok_or_else(|| AppError::NotFound(format!("Portfolio {} not found", portfolio_id)))?;
281 self.assert_is_owner(caller, &portfolio)?;
282 let shares = self.repository.list_shares(portfolio_id).await?;
283 Ok(shares.iter().map(Self::share_to_response).collect())
284 }
285
286 fn assert_is_owner(
292 &self,
293 caller: &PortfolioCaller,
294 portfolio: &Portfolio,
295 ) -> Result<(), AppError> {
296 if portfolio.owner_user_id == caller.user_id {
297 Ok(())
298 } else {
299 Err(AppError::Forbidden(format!(
300 "Portfolio {} not owned by user",
301 portfolio.id
302 )))
303 }
304 }
305
306 async fn assert_can_read(
308 &self,
309 caller: &PortfolioCaller,
310 portfolio: &Portfolio,
311 ) -> Result<(), AppError> {
312 if portfolio.owner_user_id == caller.user_id {
313 return Ok(());
314 }
315 let shares = self.repository.list_shares(portfolio.id).await?;
317 if shares
318 .iter()
319 .any(|s| s.shared_with_user_id == caller.user_id)
320 {
321 Ok(())
322 } else {
323 Err(AppError::Forbidden(format!(
329 "Portfolio {} not accessible",
330 portfolio.id
331 )))
332 }
333 }
334
335 async fn assert_can_write(
337 &self,
338 caller: &PortfolioCaller,
339 portfolio: &Portfolio,
340 ) -> Result<(), AppError> {
341 if portfolio.owner_user_id == caller.user_id {
342 return Ok(());
343 }
344 let shares = self.repository.list_shares(portfolio.id).await?;
345 if shares
346 .iter()
347 .any(|s| s.shared_with_user_id == caller.user_id && s.can_edit)
348 {
349 Ok(())
350 } else {
351 Err(AppError::Forbidden(format!(
352 "Portfolio {} not writable by user",
353 portfolio.id
354 )))
355 }
356 }
357
358 fn to_response(p: &Portfolio) -> PortfolioResponseDto {
359 PortfolioResponseDto {
360 id: p.id.to_string(),
361 owner_user_id: p.owner_user_id.to_string(),
362 name: p.name.clone(),
363 description: p.description.clone(),
364 created_at: p.created_at.to_rfc3339(),
365 updated_at: p.updated_at.to_rfc3339(),
366 }
367 }
368
369 fn share_to_response(s: &PortfolioShare) -> PortfolioShareResponseDto {
370 PortfolioShareResponseDto {
371 portfolio_id: s.portfolio_id.to_string(),
372 shared_with_user_id: s.shared_with_user_id.to_string(),
373 can_edit: s.can_edit,
374 shared_at: s.shared_at.to_rfc3339(),
375 }
376 }
377}
378
379#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::application::ports::{
387 BuildingRepository, PortfolioBuildingEntry, PortfolioRepository, UserRepository,
388 };
389 use crate::domain::entities::{Building, Portfolio, PortfolioBuilding, PortfolioShare, User};
390 use async_trait::async_trait;
391 use mockall::mock;
392
393 mock! {
394 PortfolioRepo {}
395
396 #[async_trait]
397 impl PortfolioRepository for PortfolioRepo {
398 async fn create(&self, portfolio: &Portfolio) -> Result<Portfolio, AppError>;
399 async fn find_by_id(&self, id: Uuid) -> Result<Option<Portfolio>, AppError>;
400 async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<Portfolio>, AppError>;
401 async fn update(&self, portfolio: &Portfolio) -> Result<Portfolio, AppError>;
402 async fn delete(&self, id: Uuid) -> Result<(), AppError>;
403 async fn add_building(
404 &self,
405 portfolio_id: Uuid,
406 building_id: Uuid,
407 is_favorite: bool,
408 ) -> Result<PortfolioBuilding, AppError>;
409 async fn remove_building(&self, portfolio_id: Uuid, building_id: Uuid) -> Result<(), AppError>;
410 async fn list_buildings(&self, portfolio_id: Uuid) -> Result<Vec<PortfolioBuildingEntry>, AppError>;
411 async fn share_with(
412 &self,
413 portfolio_id: Uuid,
414 shared_with_user_id: Uuid,
415 can_edit: bool,
416 ) -> Result<PortfolioShare, AppError>;
417 async fn unshare(&self, portfolio_id: Uuid, shared_with_user_id: Uuid) -> Result<(), AppError>;
418 async fn list_shares(&self, portfolio_id: Uuid) -> Result<Vec<PortfolioShare>, AppError>;
419 }
420 }
421
422 mock! {
423 BuildingRepo {}
424
425 #[async_trait]
426 impl BuildingRepository for BuildingRepo {
427 async fn create(&self, building: &Building) -> Result<Building, String>;
428 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
429 async fn find_all(&self) -> Result<Vec<Building>, String>;
430 async fn find_all_paginated(
431 &self,
432 page_request: &crate::application::dto::PageRequest,
433 filters: &crate::application::dto::BuildingFilters,
434 ) -> Result<(Vec<Building>, i64), String>;
435 async fn update(&self, building: &Building) -> Result<Building, String>;
436 async fn delete(&self, id: Uuid) -> Result<bool, String>;
437 async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
438 async fn find_by_id_with_metrics(
439 &self,
440 id: Uuid,
441 ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
442 }
443 }
444
445 mock! {
446 UserRepo {}
447
448 #[async_trait]
449 impl UserRepository for UserRepo {
450 async fn create(&self, user: &User) -> Result<User, String>;
451 async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, String>;
452 async fn find_by_email(&self, email: &str) -> Result<Option<User>, String>;
453 async fn find_all(&self) -> Result<Vec<User>, String>;
454 async fn find_page(
455 &self,
456 recherche: Option<String>,
457 role: Option<String>,
458 limit: i64,
459 offset: i64,
460 ) -> Result<Vec<User>, String>;
461 async fn count_matching(
462 &self,
463 recherche: Option<String>,
464 role: Option<String>,
465 ) -> Result<i64, String>;
466 async fn find_by_organization(&self, org_id: Uuid) -> Result<Vec<User>, String>;
467 async fn update(&self, user: &User) -> Result<User, String>;
468 async fn update_password(&self, id: Uuid, password_hash: &str) -> Result<bool, String>;
469 async fn activate(&self, id: Uuid) -> Result<Option<User>, String>;
470 async fn deactivate(&self, id: Uuid) -> Result<Option<User>, String>;
471 async fn delete(&self, id: Uuid) -> Result<bool, String>;
472 async fn count_by_organization(&self, org_id: Uuid) -> Result<i64, String>;
473 }
474 }
475
476 fn make_portfolio(owner: Uuid, name: &str) -> Portfolio {
477 Portfolio::new(owner, name.to_string(), None).unwrap()
478 }
479
480 fn make_use_cases(
481 pr: MockPortfolioRepo,
482 br: MockBuildingRepo,
483 ur: MockUserRepo,
484 ) -> PortfolioUseCases {
485 PortfolioUseCases::new(Arc::new(pr), Arc::new(br), Arc::new(ur))
486 }
487
488 #[tokio::test]
491 async fn happy_user_creates_portfolio() {
492 let user_id = Uuid::new_v4();
493 let mut pr = MockPortfolioRepo::new();
494 pr.expect_create().returning(|p| Ok(p.clone()));
495 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
496
497 let dto = CreatePortfolioDto {
498 name: "Mes immeubles favoris".to_string(),
499 description: None,
500 };
501 let resp = uc
502 .create_portfolio(&PortfolioCaller { user_id }, dto)
503 .await
504 .expect("ok");
505 assert_eq!(resp.name, "Mes immeubles favoris");
506 assert_eq!(resp.owner_user_id, user_id.to_string());
507 }
508
509 #[tokio::test]
512 async fn edge_empty_buildings_listing_returns_empty_vec() {
513 let user_id = Uuid::new_v4();
514 let portfolio = make_portfolio(user_id, "Vide");
515 let portfolio_id = portfolio.id;
516
517 let mut pr = MockPortfolioRepo::new();
518 pr.expect_find_by_id()
519 .returning(move |_| Ok(Some(portfolio.clone())));
520 pr.expect_list_buildings().returning(|_| Ok(Vec::new()));
521 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
522
523 let buildings = uc
524 .list_buildings(&PortfolioCaller { user_id }, portfolio_id)
525 .await
526 .expect("ok");
527 assert!(buildings.is_empty());
528 }
529
530 #[tokio::test]
533 async fn security_non_owner_non_shared_cannot_read_portfolio() {
534 let owner = Uuid::new_v4();
535 let other = Uuid::new_v4();
536 let portfolio = make_portfolio(owner, "Owner Portfolio");
537 let portfolio_id = portfolio.id;
538
539 let mut pr = MockPortfolioRepo::new();
540 pr.expect_find_by_id()
541 .returning(move |_| Ok(Some(portfolio.clone())));
542 pr.expect_list_shares().returning(|_| Ok(Vec::new()));
543 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
544
545 let err = uc
546 .get_portfolio(&PortfolioCaller { user_id: other }, portfolio_id)
547 .await
548 .unwrap_err();
549 match err {
550 AppError::Forbidden(_) => {}
551 other => panic!("expected Forbidden, got {:?}", other),
552 }
553 }
554
555 #[tokio::test]
556 async fn security_shared_user_can_read_but_not_share() {
557 let owner = Uuid::new_v4();
558 let shared = Uuid::new_v4();
559 let portfolio = make_portfolio(owner, "Shared Portfolio");
560 let portfolio_id = portfolio.id;
561
562 let mut pr = MockPortfolioRepo::new();
563 let pclone = portfolio.clone();
564 pr.expect_find_by_id()
565 .returning(move |_| Ok(Some(pclone.clone())));
566 pr.expect_list_shares().returning(move |_| {
567 Ok(vec![PortfolioShare {
568 portfolio_id,
569 shared_with_user_id: shared,
570 can_edit: false,
571 shared_at: chrono::Utc::now(),
572 }])
573 });
574 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
575
576 let resp = uc
578 .get_portfolio(&PortfolioCaller { user_id: shared }, portfolio_id)
579 .await
580 .expect("read ok");
581 assert_eq!(resp.id, portfolio_id.to_string());
582
583 let err = uc
585 .share_with(
586 &PortfolioCaller { user_id: shared },
587 portfolio_id,
588 SharePortfolioDto {
589 shared_with_user_id: Uuid::new_v4().to_string(),
590 can_edit: false,
591 },
592 )
593 .await
594 .unwrap_err();
595 match err {
596 AppError::Forbidden(_) => {}
597 other => panic!("expected Forbidden, got {:?}", other),
598 }
599 }
600
601 #[tokio::test]
602 async fn security_shared_read_only_cannot_add_building() {
603 let owner = Uuid::new_v4();
604 let shared = Uuid::new_v4();
605 let portfolio = make_portfolio(owner, "Shared RO");
606 let portfolio_id = portfolio.id;
607
608 let mut pr = MockPortfolioRepo::new();
609 pr.expect_find_by_id()
610 .returning(move |_| Ok(Some(portfolio.clone())));
611 pr.expect_list_shares().returning(move |_| {
612 Ok(vec![PortfolioShare {
613 portfolio_id,
614 shared_with_user_id: shared,
615 can_edit: false,
616 shared_at: chrono::Utc::now(),
617 }])
618 });
619 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
620
621 let err = uc
622 .add_building(
623 &PortfolioCaller { user_id: shared },
624 portfolio_id,
625 AddBuildingDto {
626 building_id: Uuid::new_v4().to_string(),
627 is_favorite: false,
628 },
629 )
630 .await
631 .unwrap_err();
632 match err {
633 AppError::Forbidden(_) => {}
634 other => panic!("expected Forbidden, got {:?}", other),
635 }
636 }
637
638 #[tokio::test]
641 async fn negative_create_with_empty_name_returns_validation() {
642 let user_id = Uuid::new_v4();
643 let pr = MockPortfolioRepo::new();
644 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
645 let dto = CreatePortfolioDto {
646 name: "".to_string(),
647 description: None,
648 };
649 let err = uc
650 .create_portfolio(&PortfolioCaller { user_id }, dto)
651 .await
652 .unwrap_err();
653 match err {
654 AppError::Validation(_) => {}
655 other => panic!("expected Validation, got {:?}", other),
656 }
657 }
658
659 #[tokio::test]
660 async fn negative_get_unknown_returns_not_found() {
661 let user_id = Uuid::new_v4();
662 let mut pr = MockPortfolioRepo::new();
663 pr.expect_find_by_id().returning(|_| Ok(None));
664 let uc = make_use_cases(pr, MockBuildingRepo::new(), MockUserRepo::new());
665 let err = uc
666 .get_portfolio(&PortfolioCaller { user_id }, Uuid::new_v4())
667 .await
668 .unwrap_err();
669 match err {
670 AppError::NotFound(_) => {}
671 other => panic!("expected NotFound, got {:?}", other),
672 }
673 }
674
675 #[tokio::test]
676 async fn negative_add_unknown_building_returns_not_found() {
677 let owner = Uuid::new_v4();
678 let portfolio = make_portfolio(owner, "Portfolio");
679 let portfolio_id = portfolio.id;
680
681 let mut pr = MockPortfolioRepo::new();
682 pr.expect_find_by_id()
683 .returning(move |_| Ok(Some(portfolio.clone())));
684 let mut br = MockBuildingRepo::new();
685 br.expect_find_by_id().returning(|_| Ok(None));
686 let uc = make_use_cases(pr, br, MockUserRepo::new());
687
688 let err = uc
689 .add_building(
690 &PortfolioCaller { user_id: owner },
691 portfolio_id,
692 AddBuildingDto {
693 building_id: Uuid::new_v4().to_string(),
694 is_favorite: false,
695 },
696 )
697 .await
698 .unwrap_err();
699 match err {
700 AppError::NotFound(_) => {}
701 other => panic!("expected NotFound, got {:?}", other),
702 }
703 }
704}