Skip to main content

koprogo_api/application/use_cases/
portfolio_use_cases.rs

1//! Portfolio use-cases — Story 2.1.
2//!
3//! Use-cases :
4//! - `create_portfolio`         : tout authentifié peut créer son portfolio
5//! - `get_portfolio`            : owner OU shared user
6//! - `list_portfolios`          : owner + shared
7//! - `update_portfolio`         : owner only (ou shared can_edit)
8//! - `delete_portfolio`         : owner only
9//! - `add_building`             : owner OU shared can_edit
10//! - `remove_building`          : owner OU shared can_edit
11//! - `list_buildings`           : owner OU shared (lecture seule)
12//! - `share_with`               : owner only
13//! - `unshare`                  : owner only
14//! - `list_shares`              : owner only
15//!
16//! Tous les retours sont typés `Result<T, AppError>` (CRITICAL §4).
17//! Le scope de permission est centralisé dans `assert_can_read` /
18//! `assert_can_write`.
19//!
20//! ADR refs : ADR-0011 (Portefeuille entité backend).
21
22use 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/// Caller pour les use-cases portfolio.
33///
34/// Le user est toujours identifié par `user_id` — la table est portée par
35/// `users` (cf. migration `20260601050000_create_portfolios.sql`). On ne
36/// distingue pas les rôles ici : tout authentifié peut créer son portfolio.
37/// Les permissions fines (read/write/share) se règlent par owner/shared via
38/// `assert_can_read` / `assert_can_write`.
39#[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    /// Crée un portfolio pour le caller.
64    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    /// Récupère un portfolio par id (owner OU shared).
75    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    /// Liste les portfolios visibles pour le caller.
90    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    /// Met à jour un portfolio (owner OU shared can_edit).
99    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    /// Supprime un portfolio (owner only).
117    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    /// Ajoute un building au portfolio (owner OU shared can_edit).
132    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        // Vérification d'existence — AC @negative : 404 typé sur building
149        // inexistant (sinon FK violation côté DB serait remappée en
150        // Database 500).
151        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    /// Retire un building du portfolio (owner OU shared can_edit).
176    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    /// Liste les buildings d'un portfolio (owner OU shared).
194    ///
195    /// **Tri** : favoris d'abord puis `added_at DESC` (cf. AC @happy Story 2.1).
196    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    /// Partage le portfolio avec un autre user (owner only).
219    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        // Vérification d'existence (404 si user inconnu plutôt que FK 500).
235        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    /// Retire un partage (owner only).
253    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    /// Liste les partages d'un portfolio (owner only).
271    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    // -----------------------------------------------------------------------
287    // Permissions
288    // -----------------------------------------------------------------------
289
290    /// Le caller est-il owner du portfolio ?
291    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    /// Le caller peut-il lire le portfolio (owner OU shared) ?
307    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        // Lecture autorisée si présent dans `portfolio_shares`.
316        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            // 403 typé — pas 404 pour ne pas révéler "il existe mais pas pour
324            // toi" différemment de "n'existe pas". On choisit 403 ici car
325            // l'appelant a explicitement demandé un id qu'il "ne devrait pas
326            // connaître" — la fuite d'existence n'est pas critique
327            // (UUIDs v4 non énumérables).
328            Err(AppError::Forbidden(format!(
329                "Portfolio {} not accessible",
330                portfolio.id
331            )))
332        }
333    }
334
335    /// Le caller peut-il écrire (owner OU shared can_edit) ?
336    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// ============================================================================
380// Tests — taxonomie 4-cat avec mocks (CRITICAL.md §3).
381// ============================================================================
382
383#[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    // ----- @happy ------------------------------------------------------------
489
490    #[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    // ----- @edge -------------------------------------------------------------
510
511    #[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    // ----- @security ---------------------------------------------------------
531
532    #[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        // Shared user CAN read.
577        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        // Shared user CANNOT manage shares (owner only).
584        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    // ----- @negative ---------------------------------------------------------
639
640    #[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}