Skip to main content

koprogo_api/application/use_cases/
unit_use_cases.rs

1use crate::application::dto::{
2    CreateUnitDto, PageRequest, UnitFilters, UnitResponseDto, UpdateUnitDto,
3};
4use crate::application::ports::UnitRepository;
5use crate::domain::entities::Unit;
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct UnitUseCases {
10    repository: Arc<dyn UnitRepository>,
11}
12
13impl UnitUseCases {
14    pub fn new(repository: Arc<dyn UnitRepository>) -> Self {
15        Self { repository }
16    }
17
18    pub async fn create_unit(&self, dto: CreateUnitDto) -> Result<UnitResponseDto, String> {
19        // `acp_id` est optionnel dans le DTO depuis 2026-08-27 (cf. son
20        // commentaire). La route HTTP la resout depuis le building parent
21        // avant d'appeler ce use case ; un appelant direct qui l'omet reçoit
22        // une erreur explicite plutot qu'un `unwrap` sur `None`.
23        let acp_id_raw = dto
24            .acp_id
25            .as_deref()
26            .map(str::trim)
27            .filter(|s| !s.is_empty())
28            .ok_or_else(|| "Missing acp_id".to_string())?;
29        let acp_id =
30            Uuid::parse_str(acp_id_raw).map_err(|_| "Invalid acp_id format".to_string())?;
31        let building_id = Uuid::parse_str(&dto.building_id)
32            .map_err(|_| "Invalid building ID format".to_string())?;
33
34        let unit = Unit::new(
35            acp_id,
36            building_id,
37            dto.unit_number,
38            dto.unit_type,
39            dto.floor,
40            dto.surface_area,
41            dto.quota,
42        )?;
43
44        let created = self.repository.create(&unit).await?;
45        Ok(self.to_response_dto(&created))
46    }
47
48    pub async fn get_unit(&self, id: Uuid) -> Result<Option<UnitResponseDto>, String> {
49        let unit = self.repository.find_by_id(id).await?;
50        Ok(unit.map(|u| self.to_response_dto(&u)))
51    }
52
53    pub async fn list_units_by_building(
54        &self,
55        building_id: Uuid,
56    ) -> Result<Vec<UnitResponseDto>, String> {
57        let units = self.repository.find_by_building(building_id).await?;
58        Ok(units.iter().map(|u| self.to_response_dto(u)).collect())
59    }
60
61    pub async fn list_units_paginated(
62        &self,
63        page_request: &PageRequest,
64        organization_id: Option<Uuid>,
65    ) -> Result<(Vec<UnitResponseDto>, i64), String> {
66        let filters = UnitFilters {
67            organization_id,
68            ..Default::default()
69        };
70
71        let (units, total) = self
72            .repository
73            .find_all_paginated(page_request, &filters)
74            .await?;
75
76        let dtos = units.iter().map(|u| self.to_response_dto(u)).collect();
77        Ok((dtos, total))
78    }
79
80    pub async fn update_unit(
81        &self,
82        id: Uuid,
83        dto: UpdateUnitDto,
84    ) -> Result<UnitResponseDto, String> {
85        // Get existing unit
86        let mut unit = self
87            .repository
88            .find_by_id(id)
89            .await?
90            .ok_or("Unit not found".to_string())?;
91
92        // Update unit fields
93        unit.unit_number = dto.unit_number;
94        unit.unit_type = dto.unit_type;
95        unit.floor = Some(dto.floor);
96        unit.surface_area = dto.surface_area;
97        unit.quota = dto.quota;
98        unit.updated_at = chrono::Utc::now();
99
100        // Validate the updated unit
101        unit.validate_update()?;
102
103        // Save updated unit
104        let updated = self.repository.update(&unit).await?;
105        Ok(self.to_response_dto(&updated))
106    }
107
108    pub async fn assign_owner(
109        &self,
110        unit_id: Uuid,
111        owner_id: Uuid,
112    ) -> Result<UnitResponseDto, String> {
113        let mut unit = self
114            .repository
115            .find_by_id(unit_id)
116            .await?
117            .ok_or_else(|| "Unit not found".to_string())?;
118
119        unit.assign_owner(owner_id);
120
121        let updated = self.repository.update(&unit).await?;
122        Ok(self.to_response_dto(&updated))
123    }
124
125    pub async fn delete_unit(&self, id: Uuid) -> Result<bool, String> {
126        // Check if unit exists
127        let _unit = self
128            .repository
129            .find_by_id(id)
130            .await?
131            .ok_or("Unit not found".to_string())?;
132
133        // Delete the unit
134        self.repository.delete(id).await
135    }
136
137    fn to_response_dto(&self, unit: &Unit) -> UnitResponseDto {
138        UnitResponseDto {
139            id: unit.id.to_string(),
140            building_id: unit.building_id.to_string(),
141            unit_number: unit.unit_number.clone(),
142            unit_type: unit.unit_type.clone(),
143            floor: unit.floor,
144            surface_area: unit.surface_area,
145            quota: unit.quota,
146            owner_id: unit.owner_id.map(|id| id.to_string()),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::domain::entities::UnitType;
155    use async_trait::async_trait;
156    use std::collections::HashMap;
157    use std::sync::Mutex;
158
159    struct MockUnitRepository {
160        items: Mutex<HashMap<Uuid, Unit>>,
161    }
162
163    impl MockUnitRepository {
164        fn new() -> Self {
165            Self {
166                items: Mutex::new(HashMap::new()),
167            }
168        }
169    }
170
171    #[async_trait]
172    impl UnitRepository for MockUnitRepository {
173        async fn create(&self, unit: &Unit) -> Result<Unit, String> {
174            let mut items = self.items.lock().unwrap();
175            items.insert(unit.id, unit.clone());
176            Ok(unit.clone())
177        }
178
179        async fn find_by_id(&self, id: Uuid) -> Result<Option<Unit>, String> {
180            let items = self.items.lock().unwrap();
181            Ok(items.get(&id).cloned())
182        }
183
184        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Unit>, String> {
185            let items = self.items.lock().unwrap();
186            Ok(items
187                .values()
188                .filter(|u| u.building_id == building_id)
189                .cloned()
190                .collect())
191        }
192
193        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<Unit>, String> {
194            let items = self.items.lock().unwrap();
195            Ok(items
196                .values()
197                .filter(|u| u.owner_id == Some(owner_id))
198                .cloned()
199                .collect())
200        }
201
202        async fn find_all_paginated(
203            &self,
204            page_request: &PageRequest,
205            _filters: &UnitFilters,
206        ) -> Result<(Vec<Unit>, i64), String> {
207            let items = self.items.lock().unwrap();
208            let all: Vec<Unit> = items.values().cloned().collect();
209            let total = all.len() as i64;
210            let offset = page_request.offset() as usize;
211            let limit = page_request.limit() as usize;
212            let page = all.into_iter().skip(offset).take(limit).collect();
213            Ok((page, total))
214        }
215
216        async fn update(&self, unit: &Unit) -> Result<Unit, String> {
217            let mut items = self.items.lock().unwrap();
218            items.insert(unit.id, unit.clone());
219            Ok(unit.clone())
220        }
221
222        async fn delete(&self, id: Uuid) -> Result<bool, String> {
223            let mut items = self.items.lock().unwrap();
224            Ok(items.remove(&id).is_some())
225        }
226    }
227
228    fn make_use_cases(repo: MockUnitRepository) -> UnitUseCases {
229        UnitUseCases::new(Arc::new(repo))
230    }
231
232    fn make_create_dto(acp_id: Uuid, building_id: Uuid) -> CreateUnitDto {
233        CreateUnitDto {
234            acp_id: Some(acp_id.to_string()),
235            building_id: building_id.to_string(),
236            unit_number: "A101".to_string(),
237            unit_type: UnitType::Apartment,
238            floor: Some(1),
239            surface_area: 85.0,
240            quota: rust_decimal_macros::dec!(50),
241        }
242    }
243
244    #[tokio::test]
245    async fn test_create_unit_success() {
246        let repo = MockUnitRepository::new();
247        let use_cases = make_use_cases(repo);
248        let acp_id = Uuid::new_v4();
249        let building_id = Uuid::new_v4();
250
251        let result = use_cases
252            .create_unit(make_create_dto(acp_id, building_id))
253            .await;
254
255        assert!(result.is_ok());
256        let dto = result.unwrap();
257        assert_eq!(dto.unit_number, "A101");
258        assert_eq!(dto.surface_area, 85.0);
259        assert_eq!(dto.quota, rust_decimal_macros::dec!(50));
260        assert_eq!(dto.building_id, building_id.to_string());
261        assert!(dto.owner_id.is_none());
262    }
263
264    #[tokio::test]
265    async fn test_create_unit_invalid_building_id() {
266        let repo = MockUnitRepository::new();
267        let use_cases = make_use_cases(repo);
268        // UUID valide quelconque : sert juste à faire passer le parse de `acp_id`
269        // pour que l'erreur testée porte bien sur le `building_id` invalide.
270        let acp_id = Uuid::new_v4();
271
272        let dto = CreateUnitDto {
273            acp_id: Some(acp_id.to_string()),
274            building_id: "not-a-valid-uuid".to_string(),
275            unit_number: "A101".to_string(),
276            unit_type: UnitType::Apartment,
277            floor: Some(1),
278            surface_area: 85.0,
279            quota: rust_decimal_macros::dec!(50),
280        };
281
282        let result = use_cases.create_unit(dto).await;
283
284        assert!(result.is_err());
285        assert_eq!(result.unwrap_err(), "Invalid building ID format");
286    }
287
288    #[tokio::test]
289    async fn test_get_unit() {
290        let repo = MockUnitRepository::new();
291        let acp_id = Uuid::new_v4();
292        let building_id = Uuid::new_v4();
293        let unit = Unit::new(
294            acp_id,
295            building_id,
296            "B202".to_string(),
297            UnitType::Parking,
298            Some(-1),
299            15.0,
300            rust_decimal_macros::dec!(10),
301        )
302        .unwrap();
303        let unit_id = unit.id;
304        repo.items.lock().unwrap().insert(unit.id, unit);
305
306        let use_cases = make_use_cases(repo);
307        let result = use_cases.get_unit(unit_id).await;
308
309        assert!(result.is_ok());
310        let dto = result.unwrap();
311        assert!(dto.is_some());
312        let dto = dto.unwrap();
313        assert_eq!(dto.unit_number, "B202");
314        assert_eq!(dto.surface_area, 15.0);
315    }
316
317    #[tokio::test]
318    async fn test_list_units_by_building() {
319        let repo = MockUnitRepository::new();
320        let acp_id = Uuid::new_v4();
321        let building_a = Uuid::new_v4();
322        let building_b = Uuid::new_v4();
323
324        let unit1 = Unit::new(
325            acp_id,
326            building_a,
327            "A101".to_string(),
328            UnitType::Apartment,
329            Some(1),
330            80.0,
331            rust_decimal_macros::dec!(40),
332        )
333        .unwrap();
334        let unit2 = Unit::new(
335            acp_id,
336            building_a,
337            "A102".to_string(),
338            UnitType::Apartment,
339            Some(1),
340            65.0,
341            rust_decimal_macros::dec!(30),
342        )
343        .unwrap();
344        let unit3 = Unit::new(
345            acp_id,
346            building_b,
347            "B101".to_string(),
348            UnitType::Commercial,
349            Some(0),
350            120.0,
351            rust_decimal_macros::dec!(100),
352        )
353        .unwrap();
354
355        {
356            let mut items = repo.items.lock().unwrap();
357            items.insert(unit1.id, unit1);
358            items.insert(unit2.id, unit2);
359            items.insert(unit3.id, unit3);
360        }
361
362        let use_cases = make_use_cases(repo);
363        let result = use_cases.list_units_by_building(building_a).await;
364
365        assert!(result.is_ok());
366        let units = result.unwrap();
367        assert_eq!(units.len(), 2);
368        assert!(units
369            .iter()
370            .all(|u| u.building_id == building_a.to_string()));
371    }
372
373    #[tokio::test]
374    async fn test_delete_unit() {
375        let repo = MockUnitRepository::new();
376        let acp_id = Uuid::new_v4();
377        let building_id = Uuid::new_v4();
378        let unit = Unit::new(
379            acp_id,
380            building_id,
381            "A101".to_string(),
382            UnitType::Apartment,
383            Some(1),
384            80.0,
385            rust_decimal_macros::dec!(50),
386        )
387        .unwrap();
388        let unit_id = unit.id;
389        repo.items.lock().unwrap().insert(unit.id, unit);
390
391        let use_cases = make_use_cases(repo);
392        let result = use_cases.delete_unit(unit_id).await;
393
394        assert!(result.is_ok());
395        assert!(result.unwrap());
396
397        // Verify it is gone
398        let get_result = use_cases.get_unit(unit_id).await;
399        assert!(get_result.is_ok());
400        assert!(get_result.unwrap().is_none());
401    }
402
403    #[tokio::test]
404    async fn test_assign_owner() {
405        let repo = MockUnitRepository::new();
406        let acp_id = Uuid::new_v4();
407        let building_id = Uuid::new_v4();
408        let unit = Unit::new(
409            acp_id,
410            building_id,
411            "A101".to_string(),
412            UnitType::Apartment,
413            Some(1),
414            80.0,
415            rust_decimal_macros::dec!(50),
416        )
417        .unwrap();
418        let unit_id = unit.id;
419        repo.items.lock().unwrap().insert(unit.id, unit);
420
421        let use_cases = make_use_cases(repo);
422        let owner_id = Uuid::new_v4();
423        let result = use_cases.assign_owner(unit_id, owner_id).await;
424
425        assert!(result.is_ok());
426        let dto = result.unwrap();
427        assert_eq!(dto.owner_id, Some(owner_id.to_string()));
428    }
429}