Skip to main content

koprogo_api/application/use_cases/
list_buildings_use_case.rs

1//! `ListBuildings` use-case — Story 1.3 (refonte UX multi-rôle).
2//!
3//! Filters the buildings list by `ListScope` derived from the caller's
4//! role. Distinct from the legacy `BuildingUseCases::list_buildings*`
5//! (which still uses `Result<_, String>` — territory of cluster #555,
6//! migrated lazily by stories that touch building_use_cases directly).
7//!
8//! This NEW use-case is `AppError`-typed end-to-end (cf. CRITICAL §4 +
9//! architecture §3.3 "no new Result<_, String>").
10//!
11//! Implementation pragmatics — Story 1.2 will add `buildings.acp_id`
12//! while keeping `organization_id` during the transition. Until the
13//! ACP-scoped filter is wired in the repository, we re-use
14//! `BuildingFilters { organization_id, owner_user_id }` and accept that
15//! `ListScope::AcpScope` is functionally equivalent to filtering by the
16//! ACP's owning organization (the `acp_id` field will be plugged in
17//! Story 1.2's PR).
18
19use crate::application::dto::{BuildingFilters, BuildingResponseDto, PageRequest};
20use crate::application::error::AppError;
21use crate::application::ports::{AcpRepository, BuildingRepository, ListScope};
22use crate::domain::entities::Building;
23use std::sync::Arc;
24use uuid::Uuid;
25
26/// Resolved scope ready to be passed to the repository layer.
27///
28/// `AcpScope` is resolved post-Story-1.2 by looking up the ACP's
29/// `organization_id` (so the existing `BuildingFilters` query still
30/// works during the transition).
31#[derive(Debug, Clone)]
32struct ResolvedScope {
33    organization_id: Option<Uuid>,
34    owner_user_id: Option<Uuid>,
35    /// If `true`, the caller has unrestricted access (admin path).
36    all: bool,
37}
38
39pub struct ListBuildingsUseCase {
40    building_repo: Arc<dyn BuildingRepository>,
41    acp_repo: Arc<dyn AcpRepository>,
42}
43
44impl ListBuildingsUseCase {
45    pub fn new(
46        building_repo: Arc<dyn BuildingRepository>,
47        acp_repo: Arc<dyn AcpRepository>,
48    ) -> Self {
49        Self {
50            building_repo,
51            acp_repo,
52        }
53    }
54
55    /// List buildings filtered by `scope` (caller-derived).
56    ///
57    /// Returns 422 / `Validation` if the caller supplied a scope that
58    /// can't be resolved (e.g. `AcpScope` with an unknown ACP id).
59    pub async fn list_for_scope(
60        &self,
61        page_request: &PageRequest,
62        scope: ListScope,
63    ) -> Result<(Vec<BuildingResponseDto>, i64), AppError> {
64        let resolved = self.resolve(scope).await?;
65
66        let filters = BuildingFilters {
67            organization_id: if resolved.all {
68                None
69            } else {
70                resolved.organization_id
71            },
72            owner_user_id: resolved.owner_user_id,
73            ..Default::default()
74        };
75
76        let (buildings, total) = self
77            .building_repo
78            .find_all_paginated(page_request, &filters)
79            .await
80            // Legacy repository still returns Result<_, String> — wrap into
81            // AppError::Database without re-introducing a Result<_, String>
82            // in this use-case's public signature.
83            .map_err(AppError::Database)?;
84
85        let dtos = buildings.iter().map(Self::to_response_dto).collect();
86        Ok((dtos, total))
87    }
88
89    /// Resolve `ListScope` into the legacy `BuildingFilters` shape.
90    ///
91    /// `AcpScope(acp_id)` is currently resolved via the ACP's
92    /// `organization_id` — story 1.2 will swap this for a direct
93    /// `acp_id` filter once `buildings.acp_id` is `NOT NULL`.
94    async fn resolve(&self, scope: ListScope) -> Result<ResolvedScope, AppError> {
95        match scope {
96            ListScope::All => Ok(ResolvedScope {
97                organization_id: None,
98                owner_user_id: None,
99                all: true,
100            }),
101            ListScope::Organization(org_id) => Ok(ResolvedScope {
102                organization_id: Some(org_id),
103                owner_user_id: None,
104                all: false,
105            }),
106            ListScope::Owner(user_id) => Ok(ResolvedScope {
107                organization_id: None,
108                owner_user_id: Some(user_id),
109                all: false,
110            }),
111        }
112    }
113
114    /// Resolve a per-ACP scope (used by scope_guard middleware once a
115    /// `X-Scope-AcpId` header is present). Looks up the ACP's
116    /// organization_id and applies that as the filter.
117    pub async fn list_for_acp(
118        &self,
119        page_request: &PageRequest,
120        acp_id: Uuid,
121    ) -> Result<(Vec<BuildingResponseDto>, i64), AppError> {
122        let acp = self
123            .acp_repo
124            .find_by_id(acp_id)
125            .await?
126            .ok_or(AppError::AcpNotInScope { acp_id })?;
127
128        // ACP without organization (auto-managed) -> no FK filter possible
129        // pre-Story-1.2; we conservatively return empty list to avoid
130        // leaking buildings of other ACPs sharing a NULL organization.
131        let org_id = match acp.organization_id {
132            Some(o) => o,
133            None => return Ok((vec![], 0)),
134        };
135
136        let filters = BuildingFilters {
137            organization_id: Some(org_id),
138            ..Default::default()
139        };
140        let (buildings, total) = self
141            .building_repo
142            .find_all_paginated(page_request, &filters)
143            .await
144            .map_err(AppError::Database)?;
145
146        let dtos = buildings.iter().map(Self::to_response_dto).collect();
147        Ok((dtos, total))
148    }
149
150    fn to_response_dto(b: &Building) -> BuildingResponseDto {
151        // Story 1.4 metrics fields default to 0/empty/false on list endpoint
152        // (per-row metrics aggregate would be expensive on paginated list ;
153        //  full metrics are exposed via GET /buildings/:id detail endpoint).
154        //
155        // Track H Story H1 — `quota_delta` fallback = `total_tantiemes`
156        // (positif = manque) au lieu de la constante hard-codée "-1000"
157        // qui était incohérente avec la convention `total - sum` et avec
158        // les actes de base ≠ 1000.
159        BuildingResponseDto {
160            id: b.id.to_string(),
161            acp_id: b.acp_id.to_string(),
162            name: b.name.clone(),
163            address: b.address.clone(),
164            city: b.city.clone(),
165            postal_code: b.postal_code.clone(),
166            country: b.country.clone(),
167            total_units: b.total_units,
168            total_tantiemes: b.total_tantiemes,
169            construction_year: b.construction_year,
170            created_at: b.created_at.to_rfc3339(),
171            updated_at: b.updated_at.to_rfc3339(),
172            units_count: 0,
173            quota_sum: String::from("0"),
174            is_conformant: false,
175            quota_delta: b.total_tantiemes.to_string(),
176        }
177    }
178}
179
180// ============================================================================
181// Tests — taxonomie 4-cat (CRITICAL.md §3).
182// ============================================================================
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::application::ports::{AcpRepository, BuildingRepository, ListScope};
188    use crate::domain::entities::{Acp, Building};
189    use async_trait::async_trait;
190    use mockall::mock;
191
192    mock! {
193        BuildingRepo {}
194
195        #[async_trait]
196        impl BuildingRepository for BuildingRepo {
197            async fn create(&self, building: &Building) -> Result<Building, String>;
198            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
199            async fn find_all(&self) -> Result<Vec<Building>, String>;
200            async fn find_all_paginated(
201                &self,
202                page_request: &PageRequest,
203                filters: &BuildingFilters,
204            ) -> Result<(Vec<Building>, i64), String>;
205            async fn update(&self, building: &Building) -> Result<Building, String>;
206            async fn delete(&self, id: Uuid) -> Result<bool, String>;
207            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
208            async fn find_by_id_with_metrics(
209                &self,
210                id: Uuid,
211            ) -> Result<
212                Option<(Building, crate::domain::entities::BuildingMetrics)>,
213                String,
214            >;
215        }
216    }
217
218    mock! {
219        AcpRepo {}
220
221        #[async_trait]
222        impl AcpRepository for AcpRepo {
223            async fn create(&self, acp: &Acp) -> Result<Acp, AppError>;
224            async fn find_by_id(&self, id: Uuid) -> Result<Option<Acp>, AppError>;
225            async fn find_by_id_with_metrics(&self, id: Uuid) -> Result<Option<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
226            async fn list(&self, scope: ListScope) -> Result<Vec<Acp>, AppError>;
227            async fn list_with_metrics(
228                &self,
229                scope: ListScope,
230            ) -> Result<Vec<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
231            async fn update(&self, acp: &Acp) -> Result<Acp, AppError>;
232            async fn archive(&self, id: Uuid) -> Result<(), AppError>;
233            async fn count_buildings(&self, id: Uuid) -> Result<i64, AppError>;
234        }
235    }
236
237    fn page() -> PageRequest {
238        PageRequest {
239            page: 1,
240            per_page: 20,
241            sort_by: None,
242            order: crate::application::dto::SortOrder::default(),
243        }
244    }
245
246    fn make_building(org: Uuid) -> Building {
247        Building::new(
248            org,
249            "Building".to_string(),
250            "Addr".to_string(),
251            "City".to_string(),
252            "1000".to_string(),
253            "BE".to_string(),
254            10,
255            1000,
256            Some(2000),
257        )
258        .expect("valid building")
259    }
260
261    fn make_acp(org_id: Option<Uuid>) -> Acp {
262        Acp::new(
263            org_id,
264            "Acp".to_string(),
265            "Rue X".to_string(),
266            "1000".to_string(),
267            "Bruxelles".to_string(),
268            None,
269        )
270        .expect("valid acp")
271    }
272
273    // ----- @happy --------------------------------------------------------------
274
275    #[tokio::test]
276    async fn happy_admin_sees_all_buildings() {
277        let mut building_repo = MockBuildingRepo::new();
278        let org = Uuid::new_v4();
279        let b = make_building(org);
280        let b_clone = b.clone();
281        building_repo
282            .expect_find_all_paginated()
283            .withf(|_pr, f| f.organization_id.is_none() && f.owner_user_id.is_none())
284            .returning(move |_, _| Ok((vec![b_clone.clone()], 1)));
285
286        let acp_repo = MockAcpRepo::new();
287        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
288        let (list, total) = uc
289            .list_for_scope(&page(), ListScope::All)
290            .await
291            .expect("ok");
292        assert_eq!(total, 1);
293        assert_eq!(list.len(), 1);
294    }
295
296    #[tokio::test]
297    async fn happy_syndic_sees_only_org_buildings() {
298        let org_a = Uuid::new_v4();
299        let mut building_repo = MockBuildingRepo::new();
300        let b = make_building(org_a);
301        let b_clone = b.clone();
302        building_repo
303            .expect_find_all_paginated()
304            .withf(move |_pr, f| f.organization_id == Some(org_a))
305            .returning(move |_, _| Ok((vec![b_clone.clone()], 1)));
306
307        let acp_repo = MockAcpRepo::new();
308        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
309        let (list, _) = uc
310            .list_for_scope(&page(), ListScope::Organization(org_a))
311            .await
312            .expect("ok");
313        assert_eq!(list.len(), 1);
314    }
315
316    #[tokio::test]
317    async fn happy_owner_sees_only_own_buildings() {
318        let user_id = Uuid::new_v4();
319        let mut building_repo = MockBuildingRepo::new();
320        building_repo
321            .expect_find_all_paginated()
322            .withf(move |_pr, f| f.owner_user_id == Some(user_id))
323            .returning(move |_, _| Ok((vec![], 0)));
324
325        let acp_repo = MockAcpRepo::new();
326        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
327        let (list, total) = uc
328            .list_for_scope(&page(), ListScope::Owner(user_id))
329            .await
330            .expect("ok");
331        assert!(list.is_empty());
332        assert_eq!(total, 0);
333    }
334
335    // ----- @edge ---------------------------------------------------------------
336
337    #[tokio::test]
338    async fn edge_list_for_acp_with_auto_managed_acp_returns_empty() {
339        let acp = make_acp(None);
340        let acp_id = acp.id;
341        let mut acp_repo = MockAcpRepo::new();
342        acp_repo
343            .expect_find_by_id()
344            .returning(move |_| Ok(Some(acp.clone())));
345
346        let building_repo = MockBuildingRepo::new();
347        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
348        let (list, total) = uc.list_for_acp(&page(), acp_id).await.expect("ok");
349        assert!(list.is_empty());
350        assert_eq!(total, 0);
351    }
352
353    // ----- @security -----------------------------------------------------------
354
355    #[tokio::test]
356    async fn security_list_for_unknown_acp_returns_acp_not_in_scope() {
357        let mut acp_repo = MockAcpRepo::new();
358        acp_repo.expect_find_by_id().returning(|_| Ok(None));
359        let building_repo = MockBuildingRepo::new();
360        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
361        let err = uc.list_for_acp(&page(), Uuid::new_v4()).await.unwrap_err();
362        assert!(matches!(err, AppError::AcpNotInScope { .. }));
363    }
364
365    // ----- @negative -----------------------------------------------------------
366
367    #[tokio::test]
368    async fn negative_repository_error_maps_to_database_apperror() {
369        let mut building_repo = MockBuildingRepo::new();
370        building_repo
371            .expect_find_all_paginated()
372            .returning(|_, _| Err("connection lost".to_string()));
373        let acp_repo = MockAcpRepo::new();
374        let uc = ListBuildingsUseCase::new(Arc::new(building_repo), Arc::new(acp_repo));
375        let err = uc
376            .list_for_scope(&page(), ListScope::All)
377            .await
378            .unwrap_err();
379        match err {
380            AppError::Database(msg) => assert!(msg.contains("connection lost")),
381            other => panic!("expected Database, got {:?}", other),
382        }
383    }
384}