Skip to main content

koprogo_api/application/use_cases/
shared_object_use_cases.rs

1use crate::application::dto::{
2    BorrowObjectDto, CategoryObjectCount, CreateSharedObjectDto, SharedObjectResponseDto,
3    SharedObjectStatisticsDto, SharedObjectSummaryDto, UpdateSharedObjectDto,
4};
5use crate::application::ports::{
6    OwnerCreditBalanceRepository, OwnerRepository, SharedObjectRepository,
7};
8use crate::domain::entities::{SharedObject, SharedObjectCategory};
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct SharedObjectUseCases {
13    shared_object_repo: Arc<dyn SharedObjectRepository>,
14    owner_repo: Arc<dyn OwnerRepository>,
15    credit_balance_repo: Arc<dyn OwnerCreditBalanceRepository>,
16}
17
18impl SharedObjectUseCases {
19    pub fn new(
20        shared_object_repo: Arc<dyn SharedObjectRepository>,
21        owner_repo: Arc<dyn OwnerRepository>,
22        credit_balance_repo: Arc<dyn OwnerCreditBalanceRepository>,
23    ) -> Self {
24        Self {
25            shared_object_repo,
26            owner_repo,
27            credit_balance_repo,
28        }
29    }
30
31    /// Resolve user_id to owner via organization lookup
32    async fn resolve_owner(
33        &self,
34        user_id: Uuid,
35        organization_id: Uuid,
36    ) -> Result<crate::domain::entities::Owner, String> {
37        self.owner_repo
38            .find_by_user_id_and_organization(user_id, organization_id)
39            .await?
40            // Refus LÉGITIME, message illisible.
41            //
42            // Le partage d'objets, l'offre de compétence et la réservation
43            // engagent une personne nommée : ils supposent donc une fiche de
44            // copropriétaire. Un syndic n'en a pas, et c'est normal.
45            //
46            // Ce qui ne l'était pas, c'est le message. « Owner not found for
47            // this user in the organization » est en anglais, technique, et ne
48            // dit pas à l'utilisateur ce qu'il devrait faire. En recette le
49            // 2026-09-06 (RN-11), il a été lu comme une panne bloquant quatre
50            // modules, alors que la création d'ANNONCE par un syndic fonctionne
51            // — parce qu'un avis, lui, émane de la copropriété.
52            //
53            // Le vrai manque est ailleurs : le syndic agissant pour le compte
54            // de l'ACP, prévu par la story #588 et non implémenté.
55            .ok_or_else(|| crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES.to_string())
56    }
57
58    /// Create a new shared object
59    ///
60    /// # Authorization
61    /// - Must be an owner in the organization (owners lend to other owners/tenants)
62    pub async fn create_shared_object(
63        &self,
64        user_id: Uuid,
65        organization_id: Uuid,
66        dto: CreateSharedObjectDto,
67    ) -> Result<SharedObjectResponseDto, String> {
68        let owner = self.resolve_owner(user_id, organization_id).await?;
69        let owner_id = owner.id;
70
71        let object = SharedObject::new(
72            owner_id,
73            dto.building_id,
74            dto.object_category,
75            dto.object_name,
76            dto.description,
77            dto.condition,
78            dto.is_available,
79            dto.rental_credits_per_day,
80            dto.deposit_credits,
81            dto.borrowing_duration_days,
82            dto.photos,
83            dto.location_details,
84            dto.usage_instructions,
85        )?;
86
87        let created = self.shared_object_repo.create(&object).await?;
88
89        let owner_name = format!("{} {}", owner.first_name, owner.last_name);
90        Ok(SharedObjectResponseDto::from_shared_object(
91            created, owner_name, None,
92        ))
93    }
94
95    /// Get shared object by ID with owner/borrower name enrichment
96    pub async fn get_shared_object(
97        &self,
98        object_id: Uuid,
99    ) -> Result<SharedObjectResponseDto, String> {
100        let object = self
101            .shared_object_repo
102            .find_by_id(object_id)
103            .await?
104            .ok_or("Shared object not found".to_string())?;
105
106        let owner = self
107            .owner_repo
108            .find_by_id(object.owner_id)
109            .await?
110            .ok_or("Owner not found".to_string())?;
111        let owner_name = format!("{} {}", owner.first_name, owner.last_name);
112
113        // Enrich with borrower name if borrowed
114        let borrower_name = if let Some(borrower_id) = object.current_borrower_id {
115            let borrower = self.owner_repo.find_by_id(borrower_id).await?;
116            borrower.map(|b| format!("{} {}", b.first_name, b.last_name))
117        } else {
118            None
119        };
120
121        Ok(SharedObjectResponseDto::from_shared_object(
122            object,
123            owner_name,
124            borrower_name,
125        ))
126    }
127
128    /// List all shared objects for a building
129    ///
130    /// # Returns
131    /// - Objects sorted by available (DESC), object_name (ASC)
132    pub async fn list_building_objects(
133        &self,
134        building_id: Uuid,
135    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
136        let objects = self
137            .shared_object_repo
138            .find_by_building(building_id)
139            .await?;
140        self.enrich_objects_summary(objects).await
141    }
142
143    /// List available shared objects for a building (marketplace view)
144    ///
145    /// # Returns
146    /// - Only available objects (is_available = true)
147    pub async fn list_available_objects(
148        &self,
149        building_id: Uuid,
150    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
151        let objects = self
152            .shared_object_repo
153            .find_available_by_building(building_id)
154            .await?;
155        self.enrich_objects_summary(objects).await
156    }
157
158    /// List borrowed shared objects for a building
159    pub async fn list_borrowed_objects(
160        &self,
161        building_id: Uuid,
162    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
163        let objects = self
164            .shared_object_repo
165            .find_borrowed_by_building(building_id)
166            .await?;
167        self.enrich_objects_summary(objects).await
168    }
169
170    /// List overdue shared objects for a building
171    pub async fn list_overdue_objects(
172        &self,
173        building_id: Uuid,
174    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
175        let objects = self
176            .shared_object_repo
177            .find_overdue_by_building(building_id)
178            .await?;
179        self.enrich_objects_summary(objects).await
180    }
181
182    /// List all shared objects created by an owner
183    pub async fn list_owner_objects(
184        &self,
185        owner_id: Uuid,
186    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
187        let objects = self.shared_object_repo.find_by_owner(owner_id).await?;
188        self.enrich_objects_summary(objects).await
189    }
190
191    /// List all shared objects currently borrowed by a user
192    pub async fn list_user_borrowed_objects(
193        &self,
194        user_id: Uuid,
195        organization_id: Uuid,
196    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
197        let owner = self.resolve_owner(user_id, organization_id).await?;
198        let objects = self
199            .shared_object_repo
200            .find_borrowed_by_user(owner.id)
201            .await?;
202        self.enrich_objects_summary(objects).await
203    }
204
205    /// List shared objects by category (Tools, Books, Electronics, etc.)
206    pub async fn list_objects_by_category(
207        &self,
208        building_id: Uuid,
209        category: SharedObjectCategory,
210    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
211        let objects = self
212            .shared_object_repo
213            .find_by_category(building_id, category)
214            .await?;
215        self.enrich_objects_summary(objects).await
216    }
217
218    /// List free/volunteer shared objects for a building
219    pub async fn list_free_objects(
220        &self,
221        building_id: Uuid,
222    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
223        let objects = self
224            .shared_object_repo
225            .find_free_by_building(building_id)
226            .await?;
227        self.enrich_objects_summary(objects).await
228    }
229
230    /// Update a shared object
231    ///
232    /// # Authorization
233    /// - Only owner can update their object
234    /// - Cannot update if currently borrowed
235    pub async fn update_shared_object(
236        &self,
237        object_id: Uuid,
238        user_id: Uuid,
239        organization_id: Uuid,
240        dto: UpdateSharedObjectDto,
241    ) -> Result<SharedObjectResponseDto, String> {
242        let owner = self.resolve_owner(user_id, organization_id).await?;
243        let mut object = self
244            .shared_object_repo
245            .find_by_id(object_id)
246            .await?
247            .ok_or("Shared object not found".to_string())?;
248
249        // Authorization: only owner can update
250        if object.owner_id != owner.id {
251            return Err("Unauthorized: only owner can update object".to_string());
252        }
253
254        // Update object (domain validates business rules including borrowed check)
255        object.update(
256            dto.object_name,
257            dto.description,
258            dto.condition,
259            dto.is_available,
260            dto.rental_credits_per_day,
261            dto.deposit_credits,
262            dto.borrowing_duration_days,
263            dto.photos,
264            dto.location_details,
265            dto.usage_instructions,
266        )?;
267
268        // Persist changes
269        let updated = self.shared_object_repo.update(&object).await?;
270
271        // Return enriched response
272        self.get_shared_object(updated.id).await
273    }
274
275    /// Mark shared object as available
276    ///
277    /// # Authorization
278    /// - Only owner can mark their object as available
279    pub async fn mark_object_available(
280        &self,
281        object_id: Uuid,
282        user_id: Uuid,
283        organization_id: Uuid,
284    ) -> Result<SharedObjectResponseDto, String> {
285        let owner = self.resolve_owner(user_id, organization_id).await?;
286        let mut object = self
287            .shared_object_repo
288            .find_by_id(object_id)
289            .await?
290            .ok_or("Shared object not found".to_string())?;
291
292        // Authorization: only owner can mark available
293        if object.owner_id != owner.id {
294            return Err("Unauthorized: only owner can mark object as available".to_string());
295        }
296
297        // Mark available (checks not borrowed)
298        object.mark_available()?;
299
300        // Persist changes
301        let updated = self.shared_object_repo.update(&object).await?;
302
303        // Return enriched response
304        self.get_shared_object(updated.id).await
305    }
306
307    /// Mark shared object as unavailable
308    ///
309    /// # Authorization
310    /// - Only owner can mark their object as unavailable
311    pub async fn mark_object_unavailable(
312        &self,
313        object_id: Uuid,
314        user_id: Uuid,
315        organization_id: Uuid,
316    ) -> Result<SharedObjectResponseDto, String> {
317        let owner = self.resolve_owner(user_id, organization_id).await?;
318        let mut object = self
319            .shared_object_repo
320            .find_by_id(object_id)
321            .await?
322            .ok_or("Shared object not found".to_string())?;
323
324        // Authorization: only owner can mark unavailable
325        if object.owner_id != owner.id {
326            return Err("Unauthorized: only owner can mark object as unavailable".to_string());
327        }
328
329        // Mark unavailable
330        object.mark_unavailable();
331
332        // Persist changes
333        let updated = self.shared_object_repo.update(&object).await?;
334
335        // Return enriched response
336        self.get_shared_object(updated.id).await
337    }
338
339    /// Borrow a shared object
340    ///
341    /// # Authorization
342    /// - Borrower must not be the owner
343    /// - Object must be available
344    ///
345    /// # SEL Integration
346    /// - For paid objects, holds deposit from borrower's credit balance
347    /// - Rental fee calculated on return based on actual days borrowed
348    pub async fn borrow_object(
349        &self,
350        object_id: Uuid,
351        user_id: Uuid,
352        organization_id: Uuid,
353        dto: BorrowObjectDto,
354    ) -> Result<SharedObjectResponseDto, String> {
355        let borrower = self.resolve_owner(user_id, organization_id).await?;
356        let borrower_id = borrower.id;
357        let mut object = self
358            .shared_object_repo
359            .find_by_id(object_id)
360            .await?
361            .ok_or("Shared object not found".to_string())?;
362
363        // SEL Integration: Hold deposit for paid objects
364        if let Some(deposit_amount) = object.deposit_credits {
365            if deposit_amount > 0 {
366                // Get or create borrower's credit balance
367                let mut borrower_balance = self
368                    .credit_balance_repo
369                    .get_or_create(borrower_id, object.building_id)
370                    .await?;
371
372                // Check if borrower has sufficient balance (allowing negative for trust-based)
373                // But we still validate to warn if balance goes too negative
374                let new_balance = borrower_balance.balance - deposit_amount;
375                if new_balance < -100 {
376                    // Trust limit: -100 credits
377                    return Err(format!(
378                        "Insufficient credit balance. Deposit required: {} credits. \
379                        Your balance: {} credits. Trust limit: -100 credits.",
380                        deposit_amount, borrower_balance.balance
381                    ));
382                }
383
384                // Hold deposit (spend from borrower's balance)
385                borrower_balance.spend_credits(deposit_amount)?;
386
387                // Persist balance change
388                self.credit_balance_repo.update(&borrower_balance).await?;
389            }
390        }
391
392        // Borrow object (validates business rules: owner != borrower, is_available, etc.)
393        object.borrow(borrower_id, dto.duration_days)?;
394
395        // Persist changes
396        let updated = self.shared_object_repo.update(&object).await?;
397
398        // Return enriched response
399        self.get_shared_object(updated.id).await
400    }
401
402    /// Return a borrowed object
403    ///
404    /// # Authorization
405    /// - Only borrower can return object
406    ///
407    /// # SEL Integration
408    /// - For paid objects, calculates rental fee based on actual days
409    /// - Transfers rental credits from borrower to owner
410    /// - Refunds deposit to borrower
411    pub async fn return_object(
412        &self,
413        object_id: Uuid,
414        user_id: Uuid,
415        organization_id: Uuid,
416    ) -> Result<SharedObjectResponseDto, String> {
417        let returner = self.resolve_owner(user_id, organization_id).await?;
418        let returner_id = returner.id;
419        let mut object = self
420            .shared_object_repo
421            .find_by_id(object_id)
422            .await?
423            .ok_or("Shared object not found".to_string())?;
424
425        // SEL Integration: Calculate and transfer credits for paid objects
426        let (rental_cost, deposit) = object.calculate_total_cost();
427
428        if rental_cost > 0 || deposit > 0 {
429            let borrower_id = object
430                .current_borrower_id
431                .ok_or("No current borrower to refund".to_string())?;
432            let owner_id = object.owner_id;
433
434            // Get or create borrower's credit balance
435            let mut borrower_balance = self
436                .credit_balance_repo
437                .get_or_create(borrower_id, object.building_id)
438                .await?;
439
440            // Get or create owner's credit balance
441            let mut owner_balance = self
442                .credit_balance_repo
443                .get_or_create(owner_id, object.building_id)
444                .await?;
445
446            // Transfer rental cost from borrower to owner
447            if rental_cost > 0 {
448                borrower_balance.spend_credits(rental_cost)?;
449                owner_balance.earn_credits(rental_cost)?;
450            }
451
452            // Refund deposit to borrower
453            if deposit > 0 {
454                borrower_balance.earn_credits(deposit)?;
455            }
456
457            // Persist balance changes
458            self.credit_balance_repo.update(&borrower_balance).await?;
459            self.credit_balance_repo.update(&owner_balance).await?;
460        }
461
462        // Return object (validates only borrower can return)
463        object.return_object(returner_id)?;
464
465        // Persist changes
466        let updated = self.shared_object_repo.update(&object).await?;
467
468        // Return enriched response
469        self.get_shared_object(updated.id).await
470    }
471
472    /// Delete a shared object
473    ///
474    /// # Authorization
475    /// - Only owner can delete their object
476    /// - Cannot delete if currently borrowed
477    ///
478    /// # Authorization (Story 5.3 — #587, INV-4)
479    /// - The listing's owner can delete it, no reason needed (self-service).
480    /// - A community moderator (syndic, `community.moderator`, superadmin)
481    ///   who is NOT the owner can delete a litigious listing, but a reason is
482    ///   mandatory (audit trail).
483    /// - Anyone else is refused — with the pre-existing, named refusal when
484    ///   they hold no `Owner` record at all in this organization
485    ///   ([`crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES`]),
486    ///   distinct from a legitimate-but-wrong owner.
487    pub async fn delete_shared_object(
488        &self,
489        object_id: Uuid,
490        user_id: Uuid,
491        organization_id: Uuid,
492        actor_role: &str,
493        reason: Option<String>,
494    ) -> Result<(), String> {
495        let owner = self
496            .owner_repo
497            .find_by_user_id_and_organization(user_id, organization_id)
498            .await?;
499
500        let object = self
501            .shared_object_repo
502            .find_by_id(object_id)
503            .await?
504            .ok_or("Shared object not found".to_string())?;
505
506        let is_owner = owner
507            .as_ref()
508            .map(|o| object.owner_id == o.id)
509            .unwrap_or(false);
510
511        if !is_owner {
512            if crate::application::community_permissions::peut_moderer(actor_role) {
513                reason.filter(|r| !r.trim().is_empty()).ok_or_else(|| {
514                    crate::application::error::MOTIF_MODERATION_REQUIS.to_string()
515                })?;
516            } else if owner.is_none() {
517                return Err(
518                    crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES.to_string()
519                );
520            } else {
521                return Err("Unauthorized: only owner can delete object".to_string());
522            }
523        }
524
525        // Business rule: cannot delete if borrowed
526        if object.is_borrowed() {
527            return Err("Cannot delete object while it is borrowed".to_string());
528        }
529
530        // Delete object
531        self.shared_object_repo.delete(object_id).await?;
532
533        Ok(())
534    }
535
536    /// Get shared object statistics for a building
537    pub async fn get_object_statistics(
538        &self,
539        building_id: Uuid,
540    ) -> Result<SharedObjectStatisticsDto, String> {
541        let total_objects = self
542            .shared_object_repo
543            .count_by_building(building_id)
544            .await?;
545        let available_objects = self
546            .shared_object_repo
547            .count_available_by_building(building_id)
548            .await?;
549        let borrowed_objects = self
550            .shared_object_repo
551            .count_borrowed_by_building(building_id)
552            .await?;
553        let overdue_objects = self
554            .shared_object_repo
555            .count_overdue_by_building(building_id)
556            .await?;
557
558        // Calculate free/paid objects
559        let objects = self
560            .shared_object_repo
561            .find_by_building(building_id)
562            .await?;
563        let free_objects = objects.iter().filter(|o| o.is_free()).count() as i64;
564        let paid_objects = total_objects - free_objects;
565
566        // Count by category
567        let mut objects_by_category = Vec::new();
568        for category in [
569            SharedObjectCategory::Tools,
570            SharedObjectCategory::Books,
571            SharedObjectCategory::Electronics,
572            SharedObjectCategory::Sports,
573            SharedObjectCategory::Gardening,
574            SharedObjectCategory::Kitchen,
575            SharedObjectCategory::Baby,
576            SharedObjectCategory::Other,
577        ] {
578            let count = self
579                .shared_object_repo
580                .count_by_category(building_id, category.clone())
581                .await?;
582            if count > 0 {
583                objects_by_category.push(CategoryObjectCount { category, count });
584            }
585        }
586
587        Ok(SharedObjectStatisticsDto {
588            total_objects,
589            available_objects,
590            borrowed_objects,
591            overdue_objects,
592            free_objects,
593            paid_objects,
594            objects_by_category,
595        })
596    }
597
598    /// Helper method to enrich objects with owner names
599    async fn enrich_objects_summary(
600        &self,
601        objects: Vec<SharedObject>,
602    ) -> Result<Vec<SharedObjectSummaryDto>, String> {
603        let mut enriched = Vec::new();
604
605        for object in objects {
606            // Get owner name
607            let owner = self.owner_repo.find_by_id(object.owner_id).await?;
608            let owner_name = if let Some(owner) = owner {
609                format!("{} {}", owner.first_name, owner.last_name)
610            } else {
611                "Unknown Owner".to_string()
612            };
613
614            enriched.push(SharedObjectSummaryDto::from_shared_object(
615                object, owner_name,
616            ));
617        }
618
619        Ok(enriched)
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::application::dto::{OwnerFilters, PageRequest};
627    use crate::application::ports::{
628        OwnerCreditBalanceRepository, OwnerRepository, SharedObjectRepository,
629    };
630    use crate::domain::entities::{
631        ObjectCondition, Owner, OwnerCreditBalance, SharedObject, SharedObjectCategory,
632    };
633    use async_trait::async_trait;
634    use std::collections::HashMap;
635    use std::sync::{Arc, Mutex};
636    use uuid::Uuid;
637
638    // ── Mock SharedObjectRepository ─────────────────────────────────────────
639    struct MockSharedObjectRepo {
640        objects: Mutex<HashMap<Uuid, SharedObject>>,
641    }
642
643    impl MockSharedObjectRepo {
644        fn new() -> Self {
645            Self {
646                objects: Mutex::new(HashMap::new()),
647            }
648        }
649    }
650
651    #[async_trait]
652    impl SharedObjectRepository for MockSharedObjectRepo {
653        async fn create(&self, object: &SharedObject) -> Result<SharedObject, String> {
654            let mut map = self.objects.lock().unwrap();
655            map.insert(object.id, object.clone());
656            Ok(object.clone())
657        }
658
659        async fn find_by_id(&self, id: Uuid) -> Result<Option<SharedObject>, String> {
660            let map = self.objects.lock().unwrap();
661            Ok(map.get(&id).cloned())
662        }
663
664        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<SharedObject>, String> {
665            let map = self.objects.lock().unwrap();
666            Ok(map
667                .values()
668                .filter(|o| o.building_id == building_id)
669                .cloned()
670                .collect())
671        }
672
673        async fn find_available_by_building(
674            &self,
675            building_id: Uuid,
676        ) -> Result<Vec<SharedObject>, String> {
677            let map = self.objects.lock().unwrap();
678            Ok(map
679                .values()
680                .filter(|o| o.building_id == building_id && o.is_available)
681                .cloned()
682                .collect())
683        }
684
685        async fn find_borrowed_by_building(
686            &self,
687            building_id: Uuid,
688        ) -> Result<Vec<SharedObject>, String> {
689            let map = self.objects.lock().unwrap();
690            Ok(map
691                .values()
692                .filter(|o| o.building_id == building_id && o.is_borrowed())
693                .cloned()
694                .collect())
695        }
696
697        async fn find_overdue_by_building(
698            &self,
699            building_id: Uuid,
700        ) -> Result<Vec<SharedObject>, String> {
701            let map = self.objects.lock().unwrap();
702            Ok(map
703                .values()
704                .filter(|o| o.building_id == building_id && o.is_overdue())
705                .cloned()
706                .collect())
707        }
708
709        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<SharedObject>, String> {
710            let map = self.objects.lock().unwrap();
711            Ok(map
712                .values()
713                .filter(|o| o.owner_id == owner_id)
714                .cloned()
715                .collect())
716        }
717
718        async fn find_borrowed_by_user(
719            &self,
720            borrower_id: Uuid,
721        ) -> Result<Vec<SharedObject>, String> {
722            let map = self.objects.lock().unwrap();
723            Ok(map
724                .values()
725                .filter(|o| o.current_borrower_id == Some(borrower_id))
726                .cloned()
727                .collect())
728        }
729
730        async fn find_by_category(
731            &self,
732            building_id: Uuid,
733            category: SharedObjectCategory,
734        ) -> Result<Vec<SharedObject>, String> {
735            let map = self.objects.lock().unwrap();
736            Ok(map
737                .values()
738                .filter(|o| o.building_id == building_id && o.object_category == category)
739                .cloned()
740                .collect())
741        }
742
743        async fn find_free_by_building(
744            &self,
745            building_id: Uuid,
746        ) -> Result<Vec<SharedObject>, String> {
747            let map = self.objects.lock().unwrap();
748            Ok(map
749                .values()
750                .filter(|o| o.building_id == building_id && o.is_free())
751                .cloned()
752                .collect())
753        }
754
755        async fn update(&self, object: &SharedObject) -> Result<SharedObject, String> {
756            let mut map = self.objects.lock().unwrap();
757            map.insert(object.id, object.clone());
758            Ok(object.clone())
759        }
760
761        async fn delete(&self, id: Uuid) -> Result<(), String> {
762            let mut map = self.objects.lock().unwrap();
763            map.remove(&id);
764            Ok(())
765        }
766
767        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
768            let map = self.objects.lock().unwrap();
769            Ok(map
770                .values()
771                .filter(|o| o.building_id == building_id)
772                .count() as i64)
773        }
774
775        async fn count_available_by_building(&self, building_id: Uuid) -> Result<i64, String> {
776            let map = self.objects.lock().unwrap();
777            Ok(map
778                .values()
779                .filter(|o| o.building_id == building_id && o.is_available)
780                .count() as i64)
781        }
782
783        async fn count_borrowed_by_building(&self, building_id: Uuid) -> Result<i64, String> {
784            let map = self.objects.lock().unwrap();
785            Ok(map
786                .values()
787                .filter(|o| o.building_id == building_id && o.is_borrowed())
788                .count() as i64)
789        }
790
791        async fn count_overdue_by_building(&self, building_id: Uuid) -> Result<i64, String> {
792            let map = self.objects.lock().unwrap();
793            Ok(map
794                .values()
795                .filter(|o| o.building_id == building_id && o.is_overdue())
796                .count() as i64)
797        }
798
799        async fn count_by_category(
800            &self,
801            building_id: Uuid,
802            category: SharedObjectCategory,
803        ) -> Result<i64, String> {
804            let map = self.objects.lock().unwrap();
805            Ok(map
806                .values()
807                .filter(|o| o.building_id == building_id && o.object_category == category)
808                .count() as i64)
809        }
810    }
811
812    // ── Mock OwnerRepository ────────────────────────────────────────────────
813    struct MockOwnerRepo {
814        owners: Mutex<HashMap<Uuid, Owner>>,
815    }
816
817    impl MockOwnerRepo {
818        fn new() -> Self {
819            Self {
820                owners: Mutex::new(HashMap::new()),
821            }
822        }
823        fn add_owner(&self, owner: Owner) {
824            self.owners.lock().unwrap().insert(owner.id, owner);
825        }
826    }
827
828    #[async_trait]
829    impl OwnerRepository for MockOwnerRepo {
830        async fn create(&self, owner: &Owner) -> Result<Owner, String> {
831            self.owners.lock().unwrap().insert(owner.id, owner.clone());
832            Ok(owner.clone())
833        }
834        async fn find_by_id(&self, id: Uuid) -> Result<Option<Owner>, String> {
835            Ok(self.owners.lock().unwrap().get(&id).cloned())
836        }
837        async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<Owner>, String> {
838            Ok(self
839                .owners
840                .lock()
841                .unwrap()
842                .values()
843                .find(|o| o.user_id == Some(user_id))
844                .cloned())
845        }
846        async fn find_by_user_id_and_organization(
847            &self,
848            user_id: Uuid,
849            org_id: Uuid,
850        ) -> Result<Option<Owner>, String> {
851            Ok(self
852                .owners
853                .lock()
854                .unwrap()
855                .values()
856                .find(|o| o.user_id == Some(user_id) && o.organization_id == org_id)
857                .cloned())
858        }
859        async fn find_by_email(&self, email: &str) -> Result<Option<Owner>, String> {
860            Ok(self
861                .owners
862                .lock()
863                .unwrap()
864                .values()
865                .find(|o| o.email == email)
866                .cloned())
867        }
868        async fn find_all(&self) -> Result<Vec<Owner>, String> {
869            Ok(self.owners.lock().unwrap().values().cloned().collect())
870        }
871        async fn find_all_paginated(
872            &self,
873            _p: &PageRequest,
874            _f: &OwnerFilters,
875        ) -> Result<(Vec<Owner>, i64), String> {
876            let all: Vec<_> = self.owners.lock().unwrap().values().cloned().collect();
877            let c = all.len() as i64;
878            Ok((all, c))
879        }
880        async fn update(&self, owner: &Owner) -> Result<Owner, String> {
881            self.owners.lock().unwrap().insert(owner.id, owner.clone());
882            Ok(owner.clone())
883        }
884        async fn delete(&self, id: Uuid) -> Result<bool, String> {
885            Ok(self.owners.lock().unwrap().remove(&id).is_some())
886        }
887        async fn set_user_link(
888            &self,
889            owner_id: Uuid,
890            user_id: Option<Uuid>,
891        ) -> Result<bool, String> {
892            let mut map = self.owners.lock().unwrap();
893            if let Some(o) = map.get_mut(&owner_id) {
894                o.user_id = user_id;
895                Ok(true)
896            } else {
897                Ok(false)
898            }
899        }
900    }
901
902    // ── Mock OwnerCreditBalanceRepository ────────────────────────────────────
903    struct MockCreditBalanceRepo {
904        balances: Mutex<HashMap<(Uuid, Uuid), OwnerCreditBalance>>,
905    }
906
907    impl MockCreditBalanceRepo {
908        fn new() -> Self {
909            Self {
910                balances: Mutex::new(HashMap::new()),
911            }
912        }
913    }
914
915    #[async_trait]
916    impl OwnerCreditBalanceRepository for MockCreditBalanceRepo {
917        async fn create(&self, balance: &OwnerCreditBalance) -> Result<OwnerCreditBalance, String> {
918            let mut map = self.balances.lock().unwrap();
919            map.insert((balance.owner_id, balance.building_id), balance.clone());
920            Ok(balance.clone())
921        }
922        async fn find_by_owner_and_building(
923            &self,
924            owner_id: Uuid,
925            building_id: Uuid,
926        ) -> Result<Option<OwnerCreditBalance>, String> {
927            Ok(self
928                .balances
929                .lock()
930                .unwrap()
931                .get(&(owner_id, building_id))
932                .cloned())
933        }
934        async fn find_by_building(
935            &self,
936            building_id: Uuid,
937        ) -> Result<Vec<OwnerCreditBalance>, String> {
938            Ok(self
939                .balances
940                .lock()
941                .unwrap()
942                .values()
943                .filter(|b| b.building_id == building_id)
944                .cloned()
945                .collect())
946        }
947        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerCreditBalance>, String> {
948            Ok(self
949                .balances
950                .lock()
951                .unwrap()
952                .values()
953                .filter(|b| b.owner_id == owner_id)
954                .cloned()
955                .collect())
956        }
957        async fn get_or_create(
958            &self,
959            owner_id: Uuid,
960            building_id: Uuid,
961        ) -> Result<OwnerCreditBalance, String> {
962            let mut map = self.balances.lock().unwrap();
963            let key = (owner_id, building_id);
964            if let Some(existing) = map.get(&key) {
965                Ok(existing.clone())
966            } else {
967                let balance = OwnerCreditBalance::new(owner_id, building_id);
968                map.insert(key, balance.clone());
969                Ok(balance)
970            }
971        }
972        async fn update(&self, balance: &OwnerCreditBalance) -> Result<OwnerCreditBalance, String> {
973            let mut map = self.balances.lock().unwrap();
974            map.insert((balance.owner_id, balance.building_id), balance.clone());
975            Ok(balance.clone())
976        }
977        async fn delete(&self, owner_id: Uuid, building_id: Uuid) -> Result<bool, String> {
978            Ok(self
979                .balances
980                .lock()
981                .unwrap()
982                .remove(&(owner_id, building_id))
983                .is_some())
984        }
985        async fn get_leaderboard(
986            &self,
987            _building_id: Uuid,
988            _limit: i32,
989        ) -> Result<Vec<OwnerCreditBalance>, String> {
990            Ok(vec![])
991        }
992        async fn count_active_participants(&self, _building_id: Uuid) -> Result<i64, String> {
993            Ok(0)
994        }
995        async fn get_total_credits_in_circulation(
996            &self,
997            _building_id: Uuid,
998        ) -> Result<i32, String> {
999            Ok(0)
1000        }
1001    }
1002
1003    // ── Helpers ─────────────────────────────────────────────────────────────
1004    fn create_test_owner(user_id: Uuid, org_id: Uuid) -> Owner {
1005        let mut owner = Owner::new(
1006            org_id,
1007            "Jean".to_string(),
1008            "Dupont".to_string(),
1009            "jean@test.com".to_string(),
1010            None,
1011            "Rue Test".to_string(),
1012            "Brussels".to_string(),
1013            "1000".to_string(),
1014            "Belgium".to_string(),
1015        )
1016        .unwrap();
1017        owner.user_id = Some(user_id);
1018        owner
1019    }
1020
1021    fn setup() -> (SharedObjectUseCases, Uuid, Uuid, Uuid, Uuid) {
1022        let user_id = Uuid::new_v4();
1023        let org_id = Uuid::new_v4();
1024        let building_id = Uuid::new_v4();
1025
1026        let obj_repo = Arc::new(MockSharedObjectRepo::new());
1027        let owner_repo = Arc::new(MockOwnerRepo::new());
1028        let credit_repo = Arc::new(MockCreditBalanceRepo::new());
1029
1030        let owner = create_test_owner(user_id, org_id);
1031        let owner_id = owner.id;
1032        owner_repo.add_owner(owner);
1033
1034        let uc = SharedObjectUseCases::new(
1035            obj_repo as Arc<dyn SharedObjectRepository>,
1036            owner_repo as Arc<dyn OwnerRepository>,
1037            credit_repo as Arc<dyn OwnerCreditBalanceRepository>,
1038        );
1039
1040        (uc, user_id, org_id, building_id, owner_id)
1041    }
1042
1043    fn make_create_dto(building_id: Uuid) -> CreateSharedObjectDto {
1044        CreateSharedObjectDto {
1045            building_id,
1046            object_category: SharedObjectCategory::Tools,
1047            object_name: "Power Drill".to_string(),
1048            description: "18V cordless drill with battery".to_string(),
1049            condition: ObjectCondition::Good,
1050            is_available: true,
1051            rental_credits_per_day: Some(2),
1052            deposit_credits: Some(10),
1053            borrowing_duration_days: Some(7),
1054            photos: None,
1055            location_details: Some("Basement".to_string()),
1056            usage_instructions: None,
1057        }
1058    }
1059
1060    // ── Tests ───────────────────────────────────────────────────────────────
1061
1062    #[tokio::test]
1063    async fn test_create_shared_object_success() {
1064        let (uc, user_id, org_id, building_id, _) = setup();
1065        let dto = make_create_dto(building_id);
1066        let result = uc.create_shared_object(user_id, org_id, dto).await;
1067        assert!(result.is_ok());
1068        let resp = result.unwrap();
1069        assert_eq!(resp.object_name, "Power Drill");
1070        assert_eq!(resp.owner_name, "Jean Dupont");
1071    }
1072
1073    #[tokio::test]
1074    async fn test_get_shared_object_success() {
1075        let (uc, user_id, org_id, building_id, _) = setup();
1076        let dto = make_create_dto(building_id);
1077        let created = uc.create_shared_object(user_id, org_id, dto).await.unwrap();
1078
1079        let result = uc.get_shared_object(created.id).await;
1080        assert!(result.is_ok());
1081        assert_eq!(result.unwrap().id, created.id);
1082    }
1083
1084    #[tokio::test]
1085    async fn test_get_shared_object_not_found() {
1086        let (uc, _, _, _, _) = setup();
1087        let result = uc.get_shared_object(Uuid::new_v4()).await;
1088        assert!(result.is_err());
1089        assert_eq!(result.unwrap_err(), "Shared object not found");
1090    }
1091
1092    #[tokio::test]
1093    async fn test_delete_shared_object_success() {
1094        let (uc, user_id, org_id, building_id, _) = setup();
1095        let dto = make_create_dto(building_id);
1096        let created = uc.create_shared_object(user_id, org_id, dto).await.unwrap();
1097
1098        let result = uc
1099            .delete_shared_object(created.id, user_id, org_id, "owner", None)
1100            .await;
1101        assert!(result.is_ok());
1102    }
1103
1104    #[tokio::test]
1105    async fn test_delete_shared_object_wrong_owner() {
1106        let (uc, user_id, org_id, building_id, _) = setup();
1107        let dto = make_create_dto(building_id);
1108        let created = uc.create_shared_object(user_id, org_id, dto).await.unwrap();
1109
1110        // Another user tries to delete -- will fail because user not found as owner
1111        let other = Uuid::new_v4();
1112        let result = uc
1113            .delete_shared_object(created.id, other, org_id, "owner", None)
1114            .await;
1115        assert!(result.is_err());
1116        assert_eq!(
1117            result.unwrap_err(),
1118            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES,
1119            "le refus doit être celui, nommé, opposé à qui n'est pas copropriétaire"
1120        );
1121    }
1122
1123    // ------------------------------------------------------------------------
1124    // Story 5.3 (#587), INV-4 — Syndic = community.moderator sur le partage d'objets
1125    // ------------------------------------------------------------------------
1126
1127    #[tokio::test]
1128    async fn happy_syndic_moderator_supprime_un_objet_litigieux_avec_motif() {
1129        let (uc, user_id, org_id, building_id, _) = setup();
1130        let dto = make_create_dto(building_id);
1131        let created = uc.create_shared_object(user_id, org_id, dto).await.unwrap();
1132
1133        let syndic_id = Uuid::new_v4(); // aucune fiche Owner dans cette org
1134        let result = uc
1135            .delete_shared_object(
1136                created.id,
1137                syndic_id,
1138                org_id,
1139                "syndic",
1140                Some("Objet dangereux signalé".to_string()),
1141            )
1142            .await;
1143        assert!(
1144            result.is_ok(),
1145            "un syndic modérateur doit pouvoir supprimer un objet litigieux : {:?}",
1146            result.err()
1147        );
1148        assert!(uc.get_shared_object(created.id).await.is_err());
1149    }
1150
1151    #[tokio::test]
1152    async fn negative_moderation_dun_objet_sans_motif_est_refusee() {
1153        let (uc, user_id, org_id, building_id, _) = setup();
1154        let dto = make_create_dto(building_id);
1155        let created = uc.create_shared_object(user_id, org_id, dto).await.unwrap();
1156
1157        let syndic_id = Uuid::new_v4();
1158        let result = uc
1159            .delete_shared_object(created.id, syndic_id, org_id, "syndic", None)
1160            .await;
1161        assert!(result.is_err());
1162        assert_eq!(
1163            result.unwrap_err(),
1164            crate::application::error::MOTIF_MODERATION_REQUIS
1165        );
1166    }
1167
1168    #[tokio::test]
1169    async fn edge_syndic_cumulant_le_role_owner_supprime_son_propre_objet_sans_motif() {
1170        // Le syndic a AUSSI une fiche de copropriétaire (un lot) dans cette
1171        // ACP : il agit ès qualités de copropriétaire sur SON objet, comme
1172        // n'importe quel owner — pas de motif requis (ADR-0052).
1173        let obj_repo = Arc::new(MockSharedObjectRepo::new());
1174        let owner_repo = Arc::new(MockOwnerRepo::new());
1175        let credit_repo = Arc::new(MockCreditBalanceRepo::new());
1176
1177        let syndic_owner_user_id = Uuid::new_v4();
1178        let org_id = Uuid::new_v4();
1179        let building_id = Uuid::new_v4();
1180        owner_repo.add_owner(create_test_owner(syndic_owner_user_id, org_id));
1181
1182        let uc = SharedObjectUseCases::new(
1183            obj_repo as Arc<dyn SharedObjectRepository>,
1184            owner_repo as Arc<dyn OwnerRepository>,
1185            credit_repo as Arc<dyn OwnerCreditBalanceRepository>,
1186        );
1187
1188        let dto = make_create_dto(building_id);
1189        let created = uc
1190            .create_shared_object(syndic_owner_user_id, org_id, dto)
1191            .await
1192            .unwrap();
1193
1194        let result = uc
1195            .delete_shared_object(created.id, syndic_owner_user_id, org_id, "syndic", None)
1196            .await;
1197        assert!(
1198            result.is_ok(),
1199            "le propriétaire de l'objet n'a pas à motiver sa propre suppression : {:?}",
1200            result.err()
1201        );
1202    }
1203
1204    #[tokio::test]
1205    async fn test_list_building_objects() {
1206        let (uc, user_id, org_id, building_id, _) = setup();
1207
1208        let dto1 = make_create_dto(building_id);
1209        let mut dto2 = make_create_dto(building_id);
1210        dto2.object_name = "Hammer".to_string();
1211        dto2.description = "Steel hammer for nails".to_string();
1212
1213        uc.create_shared_object(user_id, org_id, dto1)
1214            .await
1215            .unwrap();
1216        uc.create_shared_object(user_id, org_id, dto2)
1217            .await
1218            .unwrap();
1219
1220        let result = uc.list_building_objects(building_id).await;
1221        assert!(result.is_ok());
1222        assert_eq!(result.unwrap().len(), 2);
1223    }
1224
1225    #[tokio::test]
1226    async fn test_owner_not_found() {
1227        let obj_repo = Arc::new(MockSharedObjectRepo::new());
1228        let owner_repo = Arc::new(MockOwnerRepo::new());
1229        let credit_repo = Arc::new(MockCreditBalanceRepo::new());
1230        // No owner added
1231
1232        let uc = SharedObjectUseCases::new(
1233            obj_repo as Arc<dyn SharedObjectRepository>,
1234            owner_repo as Arc<dyn OwnerRepository>,
1235            credit_repo as Arc<dyn OwnerCreditBalanceRepository>,
1236        );
1237
1238        let dto = make_create_dto(Uuid::new_v4());
1239        let result = uc
1240            .create_shared_object(Uuid::new_v4(), Uuid::new_v4(), dto)
1241            .await;
1242        assert!(result.is_err());
1243        assert_eq!(
1244            result.unwrap_err(),
1245            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES,
1246            "le refus doit être celui, nommé, opposé à qui n'est pas copropriétaire"
1247        );
1248    }
1249}