Skip to main content

koprogo_api/application/use_cases/
local_exchange_use_cases.rs

1use crate::application::dto::{
2    CancelExchangeDto, CompleteExchangeDto, CreateLocalExchangeDto, LocalExchangeResponseDto,
3    OwnerCreditBalanceDto, OwnerExchangeSummaryDto, RateExchangeDto, RequestExchangeDto,
4    SelStatisticsDto,
5};
6use crate::application::ports::{
7    LocalExchangeRepository, OwnerCreditBalanceRepository, OwnerRepository,
8};
9use crate::domain::entities::{ExchangeStatus, ExchangeType, LocalExchange};
10use std::sync::Arc;
11use uuid::Uuid;
12
13/// Use cases for Local Exchange Trading System (SEL)
14pub struct LocalExchangeUseCases {
15    exchange_repo: Arc<dyn LocalExchangeRepository>,
16    balance_repo: Arc<dyn OwnerCreditBalanceRepository>,
17    owner_repo: Arc<dyn OwnerRepository>,
18}
19
20impl LocalExchangeUseCases {
21    pub fn new(
22        exchange_repo: Arc<dyn LocalExchangeRepository>,
23        balance_repo: Arc<dyn OwnerCreditBalanceRepository>,
24        owner_repo: Arc<dyn OwnerRepository>,
25    ) -> Self {
26        Self {
27            exchange_repo,
28            balance_repo,
29            owner_repo,
30        }
31    }
32
33    /// Résout l'utilisateur authentifié en copropriétaire.
34    ///
35    /// Même refus que `create_exchange`, et donc même message : les échanges
36    /// locaux engagent une personne nommée, pas la copropriété. On emploie la
37    /// constante partagée plutôt qu'une formulation voisine — deux textes
38    /// différents pour un même refus font croire à deux causes.
39    async fn resolve_owner_id(&self, user_id: Uuid) -> Result<Uuid, String> {
40        let owner = self
41            .owner_repo
42            .find_by_user_id(user_id)
43            .await?
44            .ok_or_else(|| {
45                crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES.to_string()
46            })?;
47        Ok(owner.id)
48    }
49
50    /// Create a new exchange offer
51    pub async fn create_exchange(
52        &self,
53        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
54        dto: CreateLocalExchangeDto,
55    ) -> Result<LocalExchangeResponseDto, String> {
56        // Resolve user_id → owner
57        //
58        // Refus LÉGITIME, message qui l'était moins.
59        //
60        // Proposer un échange local engage une personne nommée : cela suppose
61        // une fiche de copropriétaire. Un syndic n'en a pas, et c'est normal.
62        //
63        // Mais « Provider not found - user is not linked to an owner account »
64        // est en anglais, technique, et ne dit pas à l'utilisateur ce qu'il
65        // devrait faire. C'est le même défaut que celui corrigé pour les
66        // compétences après RN-11 (2026-09-06), où un message illisible avait
67        // été pris pour une panne bloquant quatre modules.
68        //
69        // Ce refus a coûté un échec Playwright muet
70        // (`SyndicLocalExchangesJourney`), que trois runs de CI n'ont pas su
71        // expliquer faute de rapporter le corps de la réponse (#832).
72        let provider = self
73            .owner_repo
74            .find_by_user_id(user_id)
75            .await?
76            .ok_or_else(|| {
77                crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES.to_string()
78            })?;
79
80        // Create domain entity using owner's actual ID
81        let exchange = LocalExchange::new(
82            dto.building_id,
83            provider.id,
84            dto.exchange_type,
85            dto.title,
86            dto.description,
87            dto.credits,
88        )?;
89
90        // Persist
91        let created = self.exchange_repo.create(&exchange).await?;
92
93        // Return DTO with provider name
94        Ok(LocalExchangeResponseDto::from_entity(
95            created,
96            format!("{} {}", provider.first_name, provider.last_name),
97            None,
98        ))
99    }
100
101    /// Get exchange by ID
102    pub async fn get_exchange(&self, id: Uuid) -> Result<LocalExchangeResponseDto, String> {
103        let exchange = self
104            .exchange_repo
105            .find_by_id(id)
106            .await?
107            .ok_or("Exchange not found".to_string())?;
108
109        // Get provider name
110        //
111        // Cas DIFFÉRENT du précédent, malgré le message qu'ils partageaient :
112        // ici l'échange existe déjà et désigne un auteur introuvable. Ce n'est
113        // pas un refus d'autorisation mais une incohérence de données, et la
114        // confusion entre les deux envoie chercher un problème de droits là où
115        // il n'y en a pas.
116        let provider = self
117            .owner_repo
118            .find_by_id(exchange.provider_id)
119            .await?
120            .ok_or_else(|| {
121                format!(
122                    "L'auteur de cet échange est introuvable (copropriétaire {}). \
123                     La fiche a peut-être été supprimée ou anonymisée.",
124                    exchange.provider_id
125                )
126            })?;
127        let provider_name = format!("{} {}", provider.first_name, provider.last_name);
128
129        // Get requester name if exists
130        let requester_name = if let Some(requester_id) = exchange.requester_id {
131            let requester = self.owner_repo.find_by_id(requester_id).await?;
132            requester.map(|r| format!("{} {}", r.first_name, r.last_name))
133        } else {
134            None
135        };
136
137        Ok(LocalExchangeResponseDto::from_entity(
138            exchange,
139            provider_name,
140            requester_name,
141        ))
142    }
143
144    /// List all exchanges for a building
145    pub async fn list_building_exchanges(
146        &self,
147        building_id: Uuid,
148    ) -> Result<Vec<LocalExchangeResponseDto>, String> {
149        let exchanges = self.exchange_repo.find_by_building(building_id).await?;
150
151        self.enrich_exchanges_with_names(exchanges).await
152    }
153
154    /// List available exchanges (status = Offered)
155    pub async fn list_available_exchanges(
156        &self,
157        building_id: Uuid,
158    ) -> Result<Vec<LocalExchangeResponseDto>, String> {
159        let exchanges = self
160            .exchange_repo
161            .find_available_by_building(building_id)
162            .await?;
163
164        self.enrich_exchanges_with_names(exchanges).await
165    }
166
167    /// List exchanges by owner (as provider OR requester)
168    pub async fn list_owner_exchanges(
169        &self,
170        owner_id: Uuid,
171    ) -> Result<Vec<LocalExchangeResponseDto>, String> {
172        let exchanges = self.exchange_repo.find_by_owner(owner_id).await?;
173
174        self.enrich_exchanges_with_names(exchanges).await
175    }
176
177    /// List exchanges by type
178    pub async fn list_exchanges_by_type(
179        &self,
180        building_id: Uuid,
181        exchange_type: ExchangeType,
182    ) -> Result<Vec<LocalExchangeResponseDto>, String> {
183        let exchanges = self
184            .exchange_repo
185            .find_by_type(building_id, exchange_type.to_sql())
186            .await?;
187
188        self.enrich_exchanges_with_names(exchanges).await
189    }
190
191    /// Request an exchange (transition: Offered → Requested)
192    pub async fn request_exchange(
193        &self,
194        exchange_id: Uuid,
195        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
196        _dto: RequestExchangeDto,
197    ) -> Result<LocalExchangeResponseDto, String> {
198        let owner_id = self.resolve_owner_id(user_id).await?;
199
200        // Load exchange
201        let mut exchange = self
202            .exchange_repo
203            .find_by_id(exchange_id)
204            .await?
205            .ok_or("Exchange not found".to_string())?;
206
207        // Apply business logic
208        exchange.request(owner_id)?;
209
210        // Persist
211        let updated = self.exchange_repo.update(&exchange).await?;
212
213        // Return enriched DTO
214        self.get_exchange(updated.id).await
215    }
216
217    /// Start an exchange (transition: Requested → InProgress)
218    /// Only provider can start
219    pub async fn start_exchange(
220        &self,
221        exchange_id: Uuid,
222        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
223    ) -> Result<LocalExchangeResponseDto, String> {
224        let owner_id = self.resolve_owner_id(user_id).await?;
225
226        let mut exchange = self
227            .exchange_repo
228            .find_by_id(exchange_id)
229            .await?
230            .ok_or("Exchange not found".to_string())?;
231
232        exchange.start(owner_id)?;
233
234        let updated = self.exchange_repo.update(&exchange).await?;
235
236        self.get_exchange(updated.id).await
237    }
238
239    /// Complete an exchange (transition: InProgress → Completed)
240    /// Updates credit balances for both parties
241    pub async fn complete_exchange(
242        &self,
243        exchange_id: Uuid,
244        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
245        _dto: CompleteExchangeDto,
246    ) -> Result<LocalExchangeResponseDto, String> {
247        let owner_id = self.resolve_owner_id(user_id).await?;
248
249        let mut exchange = self
250            .exchange_repo
251            .find_by_id(exchange_id)
252            .await?
253            .ok_or("Exchange not found".to_string())?;
254
255        // Get requester before completing (we'll need it for balance update)
256        let requester_id = exchange
257            .requester_id
258            .ok_or("Exchange has no requester".to_string())?;
259
260        exchange.complete(owner_id)?;
261
262        let updated = self.exchange_repo.update(&exchange).await?;
263
264        // Update credit balances
265        self.update_credit_balances_on_completion(&updated).await?;
266
267        // Increment exchange counters
268        let mut provider_balance = self
269            .balance_repo
270            .get_or_create(updated.provider_id, updated.building_id)
271            .await?;
272        provider_balance.increment_exchanges();
273        self.balance_repo.update(&provider_balance).await?;
274
275        let mut requester_balance = self
276            .balance_repo
277            .get_or_create(requester_id, updated.building_id)
278            .await?;
279        requester_balance.increment_exchanges();
280        self.balance_repo.update(&requester_balance).await?;
281
282        self.get_exchange(updated.id).await
283    }
284
285    /// Cancel an exchange
286    ///
287    /// # Authorization (Story 5.3 — #587, INV-4)
288    /// - Provider or requester : annulation personnelle, motif optionnel
289    ///   (comportement inchangé).
290    /// - Un modérateur communauté (syndic, `community.moderator`,
291    ///   superadmin) qui n'est ni provider ni requester : MODÉRATION, motif
292    ///   obligatoire (audit). Un syndic qui a aussi une fiche de
293    ///   copropriétaire et qui EST provider/requester passe par la première
294    ///   branche, ès qualités de copropriétaire — le droit vient du lot, pas
295    ///   de la fonction (même raisonnement que l'ADR-0052 sur le comptable).
296    /// - Personne d'autre : refus.
297    pub async fn cancel_exchange(
298        &self,
299        exchange_id: Uuid,
300        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
301        actor_role: &str,
302        dto: CancelExchangeDto,
303    ) -> Result<LocalExchangeResponseDto, String> {
304        let owner_id = self
305            .owner_repo
306            .find_by_user_id(user_id)
307            .await?
308            .map(|o| o.id);
309
310        let mut exchange = self
311            .exchange_repo
312            .find_by_id(exchange_id)
313            .await?
314            .ok_or("Exchange not found".to_string())?;
315
316        let party_owner_id = owner_id
317            .filter(|oid| exchange.provider_id == *oid || exchange.requester_id == Some(*oid));
318
319        match party_owner_id {
320            Some(oid) => {
321                exchange.cancel(oid, dto.reason)?;
322            }
323            None if crate::application::community_permissions::peut_moderer(actor_role) => {
324                let reason = dto.reason.filter(|r| !r.trim().is_empty()).ok_or_else(|| {
325                    crate::application::error::MOTIF_MODERATION_REQUIS.to_string()
326                })?;
327                exchange.moderate_cancel(reason)?;
328            }
329            None => {
330                return Err(
331                    "Only the provider, requester, or a community moderator can cancel the exchange"
332                        .to_string(),
333                );
334            }
335        }
336
337        let updated = self.exchange_repo.update(&exchange).await?;
338
339        self.get_exchange(updated.id).await
340    }
341
342    /// Rate the provider (by requester)
343    pub async fn rate_provider(
344        &self,
345        exchange_id: Uuid,
346        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
347        dto: RateExchangeDto,
348    ) -> Result<LocalExchangeResponseDto, String> {
349        let owner_id = self.resolve_owner_id(user_id).await?;
350
351        let mut exchange = self
352            .exchange_repo
353            .find_by_id(exchange_id)
354            .await?
355            .ok_or("Exchange not found".to_string())?;
356
357        exchange.rate_provider(owner_id, dto.rating)?;
358
359        let updated = self.exchange_repo.update(&exchange).await?;
360
361        // Update provider's average rating
362        self.update_average_rating(updated.provider_id, updated.building_id)
363            .await?;
364
365        self.get_exchange(updated.id).await
366    }
367
368    /// Rate the requester (by provider)
369    pub async fn rate_requester(
370        &self,
371        exchange_id: Uuid,
372        user_id: Uuid, // From auth (user_id, resolved to owner_id internally)
373        dto: RateExchangeDto,
374    ) -> Result<LocalExchangeResponseDto, String> {
375        let owner_id = self.resolve_owner_id(user_id).await?;
376
377        let mut exchange = self
378            .exchange_repo
379            .find_by_id(exchange_id)
380            .await?
381            .ok_or("Exchange not found".to_string())?;
382
383        exchange.rate_requester(owner_id, dto.rating)?;
384
385        let updated = self.exchange_repo.update(&exchange).await?;
386
387        // Update requester's average rating
388        if let Some(requester_id) = updated.requester_id {
389            self.update_average_rating(requester_id, updated.building_id)
390                .await?;
391        }
392
393        self.get_exchange(updated.id).await
394    }
395
396    /// Delete an exchange (only if not completed)
397    /// Delete an exchange (only if not completed)
398    ///
399    /// # Authorization (Story 5.3 — #587, INV-4)
400    /// Même logique que [`Self::cancel_exchange`] : le provider supprime sans
401    /// justification, un modérateur communauté qui n'est pas le provider doit
402    /// motiver la suppression (audit).
403    pub async fn delete_exchange(
404        &self,
405        exchange_id: Uuid,
406        user_id: Uuid,
407        actor_role: &str,
408        reason: Option<String>,
409    ) -> Result<(), String> {
410        let owner_id = self
411            .owner_repo
412            .find_by_user_id(user_id)
413            .await?
414            .map(|o| o.id);
415
416        let exchange = self
417            .exchange_repo
418            .find_by_id(exchange_id)
419            .await?
420            .ok_or("Exchange not found".to_string())?;
421
422        let is_provider = owner_id
423            .map(|oid| exchange.provider_id == oid)
424            .unwrap_or(false);
425
426        if !is_provider {
427            if crate::application::community_permissions::peut_moderer(actor_role) {
428                reason.filter(|r| !r.trim().is_empty()).ok_or_else(|| {
429                    crate::application::error::MOTIF_MODERATION_REQUIS.to_string()
430                })?;
431            } else {
432                return Err(
433                    "Only the provider or a community moderator can delete the exchange"
434                        .to_string(),
435                );
436            }
437        }
438
439        // Cannot delete completed exchanges
440        if exchange.status == ExchangeStatus::Completed {
441            return Err("Cannot delete a completed exchange".to_string());
442        }
443
444        self.exchange_repo.delete(exchange_id).await?;
445
446        Ok(())
447    }
448
449    /// Get credit balance for an owner in a building
450    pub async fn get_credit_balance(
451        &self,
452        owner_id: Uuid,
453        building_id: Uuid,
454    ) -> Result<OwnerCreditBalanceDto, String> {
455        let balance = self
456            .balance_repo
457            .get_or_create(owner_id, building_id)
458            .await?;
459
460        let owner = self
461            .owner_repo
462            .find_by_id(owner_id)
463            .await?
464            .ok_or("Owner not found".to_string())?;
465        let owner_name = format!("{} {}", owner.first_name, owner.last_name);
466
467        Ok(OwnerCreditBalanceDto::from_entity(balance, owner_name))
468    }
469
470    /// Get leaderboard (top contributors)
471    pub async fn get_leaderboard(
472        &self,
473        building_id: Uuid,
474        limit: i32,
475    ) -> Result<Vec<OwnerCreditBalanceDto>, String> {
476        let balances = self
477            .balance_repo
478            .get_leaderboard(building_id, limit)
479            .await?;
480
481        let mut dtos = Vec::new();
482        for balance in balances {
483            if let Some(owner) = self.owner_repo.find_by_id(balance.owner_id).await? {
484                let owner_name = format!("{} {}", owner.first_name, owner.last_name);
485                dtos.push(OwnerCreditBalanceDto::from_entity(balance, owner_name));
486            }
487        }
488
489        Ok(dtos)
490    }
491
492    /// Get SEL statistics for a building
493    pub async fn get_statistics(&self, building_id: Uuid) -> Result<SelStatisticsDto, String> {
494        let total_exchanges = self.exchange_repo.count_by_building(building_id).await? as i32;
495
496        let active_exchanges = self
497            .exchange_repo
498            .count_by_building_and_status(building_id, ExchangeStatus::Offered.to_sql())
499            .await? as i32
500            + self
501                .exchange_repo
502                .count_by_building_and_status(building_id, ExchangeStatus::Requested.to_sql())
503                .await? as i32
504            + self
505                .exchange_repo
506                .count_by_building_and_status(building_id, ExchangeStatus::InProgress.to_sql())
507                .await? as i32;
508
509        let completed_exchanges = self
510            .exchange_repo
511            .count_by_building_and_status(building_id, ExchangeStatus::Completed.to_sql())
512            .await? as i32;
513
514        let total_credits_exchanged = self
515            .exchange_repo
516            .get_total_credits_exchanged(building_id)
517            .await?;
518
519        let active_participants = self
520            .balance_repo
521            .count_active_participants(building_id)
522            .await? as i32;
523
524        let average_exchange_rating = self
525            .exchange_repo
526            .get_average_exchange_rating(building_id)
527            .await?;
528
529        // Determine most popular exchange type
530        let service_count = self
531            .exchange_repo
532            .count_by_building_and_type(building_id, ExchangeType::Service.to_sql())
533            .await?;
534        let object_loan_count = self
535            .exchange_repo
536            .count_by_building_and_type(building_id, ExchangeType::ObjectLoan.to_sql())
537            .await?;
538        let shared_purchase_count = self
539            .exchange_repo
540            .count_by_building_and_type(building_id, ExchangeType::SharedPurchase.to_sql())
541            .await?;
542
543        let most_popular_exchange_type =
544            if service_count >= object_loan_count && service_count >= shared_purchase_count {
545                Some(ExchangeType::Service)
546            } else if object_loan_count >= shared_purchase_count {
547                Some(ExchangeType::ObjectLoan)
548            } else {
549                Some(ExchangeType::SharedPurchase)
550            };
551
552        Ok(SelStatisticsDto {
553            building_id,
554            total_exchanges,
555            active_exchanges,
556            completed_exchanges,
557            total_credits_exchanged,
558            active_participants,
559            average_exchange_rating,
560            most_popular_exchange_type,
561        })
562    }
563
564    /// Get owner exchange summary
565    pub async fn get_owner_summary(
566        &self,
567        owner_id: Uuid,
568    ) -> Result<OwnerExchangeSummaryDto, String> {
569        let owner = self
570            .owner_repo
571            .find_by_id(owner_id)
572            .await?
573            .ok_or("Owner not found".to_string())?;
574        let owner_name = format!("{} {}", owner.first_name, owner.last_name);
575
576        let exchanges = self.exchange_repo.find_by_owner(owner_id).await?;
577
578        let as_provider = exchanges
579            .iter()
580            .filter(|e| e.provider_id == owner_id)
581            .count() as i32;
582
583        let as_requester = exchanges
584            .iter()
585            .filter(|e| e.requester_id == Some(owner_id))
586            .count() as i32;
587
588        // Calculate average rating (average of all ratings received)
589        let mut total_ratings = 0;
590        let mut rating_count = 0;
591
592        for exchange in &exchanges {
593            if exchange.provider_id == owner_id {
594                if let Some(rating) = exchange.provider_rating {
595                    total_ratings += rating;
596                    rating_count += 1;
597                }
598            }
599            if exchange.requester_id == Some(owner_id) {
600                if let Some(rating) = exchange.requester_rating {
601                    total_ratings += rating;
602                    rating_count += 1;
603                }
604            }
605        }
606
607        let average_rating = if rating_count > 0 {
608            Some(total_ratings as f32 / rating_count as f32)
609        } else {
610            None
611        };
612
613        // Get recent 5 exchanges
614        let recent: Vec<_> = exchanges.into_iter().take(5).collect();
615        let recent_dtos = self.enrich_exchanges_with_names(recent).await?;
616
617        Ok(OwnerExchangeSummaryDto {
618            owner_id,
619            owner_name,
620            as_provider,
621            as_requester,
622            total_exchanges: as_provider + as_requester,
623            average_rating,
624            recent_exchanges: recent_dtos,
625        })
626    }
627
628    // Private helper methods
629
630    /// Update credit balances when exchange is completed
631    async fn update_credit_balances_on_completion(
632        &self,
633        exchange: &LocalExchange,
634    ) -> Result<(), String> {
635        let requester_id = exchange
636            .requester_id
637            .ok_or("Exchange has no requester".to_string())?;
638
639        // Provider earns credits
640        let mut provider_balance = self
641            .balance_repo
642            .get_or_create(exchange.provider_id, exchange.building_id)
643            .await?;
644        provider_balance.earn_credits(exchange.credits)?;
645        self.balance_repo.update(&provider_balance).await?;
646
647        // Requester spends credits
648        let mut requester_balance = self
649            .balance_repo
650            .get_or_create(requester_id, exchange.building_id)
651            .await?;
652        requester_balance.spend_credits(exchange.credits)?;
653        self.balance_repo.update(&requester_balance).await?;
654
655        Ok(())
656    }
657
658    /// Update average rating for an owner
659    async fn update_average_rating(&self, owner_id: Uuid, building_id: Uuid) -> Result<(), String> {
660        let exchanges = self.exchange_repo.find_by_owner(owner_id).await?;
661
662        let mut total_ratings = 0;
663        let mut rating_count = 0;
664
665        for exchange in exchanges {
666            if exchange.provider_id == owner_id {
667                if let Some(rating) = exchange.provider_rating {
668                    total_ratings += rating;
669                    rating_count += 1;
670                }
671            }
672            if exchange.requester_id == Some(owner_id) {
673                if let Some(rating) = exchange.requester_rating {
674                    total_ratings += rating;
675                    rating_count += 1;
676                }
677            }
678        }
679
680        if rating_count > 0 {
681            let average = total_ratings as f32 / rating_count as f32;
682
683            let mut balance = self
684                .balance_repo
685                .get_or_create(owner_id, building_id)
686                .await?;
687            balance.update_rating(average)?;
688            self.balance_repo.update(&balance).await?;
689        }
690
691        Ok(())
692    }
693
694    /// Enrich exchanges with owner names (provider + requester)
695    async fn enrich_exchanges_with_names(
696        &self,
697        exchanges: Vec<LocalExchange>,
698    ) -> Result<Vec<LocalExchangeResponseDto>, String> {
699        let mut dtos = Vec::new();
700
701        for exchange in exchanges {
702            // Get provider name
703            let provider = self.owner_repo.find_by_id(exchange.provider_id).await?;
704            let provider_name = if let Some(p) = provider {
705                format!("{} {}", p.first_name, p.last_name)
706            } else {
707                "Unknown".to_string()
708            };
709
710            // Get requester name if exists
711            let requester_name = if let Some(requester_id) = exchange.requester_id {
712                let requester = self.owner_repo.find_by_id(requester_id).await?;
713                requester.map(|r| format!("{} {}", r.first_name, r.last_name))
714            } else {
715                None
716            };
717
718            dtos.push(LocalExchangeResponseDto::from_entity(
719                exchange,
720                provider_name,
721                requester_name,
722            ));
723        }
724
725        Ok(dtos)
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::application::dto::{OwnerFilters, PageRequest};
733    use crate::application::ports::{
734        LocalExchangeRepository, OwnerCreditBalanceRepository, OwnerRepository,
735    };
736    use crate::domain::entities::{
737        ExchangeStatus, ExchangeType, LocalExchange, Owner, OwnerCreditBalance,
738    };
739    use async_trait::async_trait;
740    use chrono::Utc;
741    use std::collections::HashMap;
742    use std::sync::Mutex;
743
744    // ── Mock OwnerRepository ────────────────────────────────────────────
745
746    struct MockOwnerRepository {
747        owners: Mutex<HashMap<Uuid, Owner>>,
748    }
749
750    impl MockOwnerRepository {
751        fn new() -> Self {
752            Self {
753                owners: Mutex::new(HashMap::new()),
754            }
755        }
756
757        fn insert(&self, owner: Owner) {
758            self.owners.lock().unwrap().insert(owner.id, owner);
759        }
760    }
761
762    #[async_trait]
763    impl OwnerRepository for MockOwnerRepository {
764        async fn create(&self, owner: &Owner) -> Result<Owner, String> {
765            self.owners.lock().unwrap().insert(owner.id, owner.clone());
766            Ok(owner.clone())
767        }
768
769        async fn find_by_id(&self, id: Uuid) -> Result<Option<Owner>, String> {
770            Ok(self.owners.lock().unwrap().get(&id).cloned())
771        }
772
773        async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<Owner>, String> {
774            Ok(self
775                .owners
776                .lock()
777                .unwrap()
778                .values()
779                .find(|o| o.user_id == Some(user_id))
780                .cloned())
781        }
782
783        async fn find_by_user_id_and_organization(
784            &self,
785            user_id: Uuid,
786            organization_id: Uuid,
787        ) -> Result<Option<Owner>, String> {
788            Ok(self
789                .owners
790                .lock()
791                .unwrap()
792                .values()
793                .find(|o| o.user_id == Some(user_id) && o.organization_id == organization_id)
794                .cloned())
795        }
796
797        async fn find_by_email(&self, email: &str) -> Result<Option<Owner>, String> {
798            Ok(self
799                .owners
800                .lock()
801                .unwrap()
802                .values()
803                .find(|o| o.email == email)
804                .cloned())
805        }
806
807        async fn find_all(&self) -> Result<Vec<Owner>, String> {
808            Ok(self.owners.lock().unwrap().values().cloned().collect())
809        }
810
811        async fn find_all_paginated(
812            &self,
813            _page_request: &PageRequest,
814            _filters: &OwnerFilters,
815        ) -> Result<(Vec<Owner>, i64), String> {
816            let owners: Vec<_> = self.owners.lock().unwrap().values().cloned().collect();
817            let total = owners.len() as i64;
818            Ok((owners, total))
819        }
820
821        async fn update(&self, owner: &Owner) -> Result<Owner, String> {
822            self.owners.lock().unwrap().insert(owner.id, owner.clone());
823            Ok(owner.clone())
824        }
825
826        async fn delete(&self, id: Uuid) -> Result<bool, String> {
827            Ok(self.owners.lock().unwrap().remove(&id).is_some())
828        }
829        async fn set_user_link(
830            &self,
831            owner_id: Uuid,
832            user_id: Option<Uuid>,
833        ) -> Result<bool, String> {
834            let mut map = self.owners.lock().unwrap();
835            if let Some(o) = map.get_mut(&owner_id) {
836                o.user_id = user_id;
837                Ok(true)
838            } else {
839                Ok(false)
840            }
841        }
842    }
843
844    // ── Mock LocalExchangeRepository ────────────────────────────────────
845
846    struct MockLocalExchangeRepository {
847        exchanges: Mutex<HashMap<Uuid, LocalExchange>>,
848    }
849
850    impl MockLocalExchangeRepository {
851        fn new() -> Self {
852            Self {
853                exchanges: Mutex::new(HashMap::new()),
854            }
855        }
856    }
857
858    #[async_trait]
859    impl LocalExchangeRepository for MockLocalExchangeRepository {
860        async fn create(&self, exchange: &LocalExchange) -> Result<LocalExchange, String> {
861            self.exchanges
862                .lock()
863                .unwrap()
864                .insert(exchange.id, exchange.clone());
865            Ok(exchange.clone())
866        }
867
868        async fn find_by_id(&self, id: Uuid) -> Result<Option<LocalExchange>, String> {
869            Ok(self.exchanges.lock().unwrap().get(&id).cloned())
870        }
871
872        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<LocalExchange>, String> {
873            Ok(self
874                .exchanges
875                .lock()
876                .unwrap()
877                .values()
878                .filter(|e| e.building_id == building_id)
879                .cloned()
880                .collect())
881        }
882
883        async fn find_by_building_and_status(
884            &self,
885            building_id: Uuid,
886            status: &str,
887        ) -> Result<Vec<LocalExchange>, String> {
888            Ok(self
889                .exchanges
890                .lock()
891                .unwrap()
892                .values()
893                .filter(|e| e.building_id == building_id && e.status.to_sql() == status)
894                .cloned()
895                .collect())
896        }
897
898        async fn find_by_provider(&self, provider_id: Uuid) -> Result<Vec<LocalExchange>, String> {
899            Ok(self
900                .exchanges
901                .lock()
902                .unwrap()
903                .values()
904                .filter(|e| e.provider_id == provider_id)
905                .cloned()
906                .collect())
907        }
908
909        async fn find_by_requester(
910            &self,
911            requester_id: Uuid,
912        ) -> Result<Vec<LocalExchange>, String> {
913            Ok(self
914                .exchanges
915                .lock()
916                .unwrap()
917                .values()
918                .filter(|e| e.requester_id == Some(requester_id))
919                .cloned()
920                .collect())
921        }
922
923        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<LocalExchange>, String> {
924            Ok(self
925                .exchanges
926                .lock()
927                .unwrap()
928                .values()
929                .filter(|e| e.provider_id == owner_id || e.requester_id == Some(owner_id))
930                .cloned()
931                .collect())
932        }
933
934        async fn find_active_by_building(
935            &self,
936            building_id: Uuid,
937        ) -> Result<Vec<LocalExchange>, String> {
938            Ok(self
939                .exchanges
940                .lock()
941                .unwrap()
942                .values()
943                .filter(|e| e.building_id == building_id && e.is_active())
944                .cloned()
945                .collect())
946        }
947
948        async fn find_available_by_building(
949            &self,
950            building_id: Uuid,
951        ) -> Result<Vec<LocalExchange>, String> {
952            Ok(self
953                .exchanges
954                .lock()
955                .unwrap()
956                .values()
957                .filter(|e| e.building_id == building_id && e.status == ExchangeStatus::Offered)
958                .cloned()
959                .collect())
960        }
961
962        async fn find_by_type(
963            &self,
964            building_id: Uuid,
965            exchange_type: &str,
966        ) -> Result<Vec<LocalExchange>, String> {
967            Ok(self
968                .exchanges
969                .lock()
970                .unwrap()
971                .values()
972                .filter(|e| {
973                    e.building_id == building_id && e.exchange_type.to_sql() == exchange_type
974                })
975                .cloned()
976                .collect())
977        }
978
979        async fn update(&self, exchange: &LocalExchange) -> Result<LocalExchange, String> {
980            self.exchanges
981                .lock()
982                .unwrap()
983                .insert(exchange.id, exchange.clone());
984            Ok(exchange.clone())
985        }
986
987        async fn delete(&self, id: Uuid) -> Result<bool, String> {
988            Ok(self.exchanges.lock().unwrap().remove(&id).is_some())
989        }
990
991        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
992            Ok(self
993                .exchanges
994                .lock()
995                .unwrap()
996                .values()
997                .filter(|e| e.building_id == building_id)
998                .count() as i64)
999        }
1000
1001        async fn count_by_building_and_status(
1002            &self,
1003            building_id: Uuid,
1004            status: &str,
1005        ) -> Result<i64, String> {
1006            Ok(self
1007                .exchanges
1008                .lock()
1009                .unwrap()
1010                .values()
1011                .filter(|e| e.building_id == building_id && e.status.to_sql() == status)
1012                .count() as i64)
1013        }
1014
1015        async fn count_by_building_and_type(
1016            &self,
1017            building_id: Uuid,
1018            exchange_type: &str,
1019        ) -> Result<i64, String> {
1020            Ok(self
1021                .exchanges
1022                .lock()
1023                .unwrap()
1024                .values()
1025                .filter(|e| {
1026                    e.building_id == building_id && e.exchange_type.to_sql() == exchange_type
1027                })
1028                .count() as i64)
1029        }
1030
1031        async fn get_total_credits_exchanged(&self, building_id: Uuid) -> Result<i32, String> {
1032            Ok(self
1033                .exchanges
1034                .lock()
1035                .unwrap()
1036                .values()
1037                .filter(|e| e.building_id == building_id && e.status == ExchangeStatus::Completed)
1038                .map(|e| e.credits)
1039                .sum())
1040        }
1041
1042        async fn get_average_exchange_rating(
1043            &self,
1044            building_id: Uuid,
1045        ) -> Result<Option<f32>, String> {
1046            let ratings: Vec<f32> = self
1047                .exchanges
1048                .lock()
1049                .unwrap()
1050                .values()
1051                .filter(|e| e.building_id == building_id)
1052                .flat_map(|e| {
1053                    let mut v = Vec::new();
1054                    if let Some(r) = e.provider_rating {
1055                        v.push(r as f32);
1056                    }
1057                    if let Some(r) = e.requester_rating {
1058                        v.push(r as f32);
1059                    }
1060                    v
1061                })
1062                .collect();
1063            if ratings.is_empty() {
1064                Ok(None)
1065            } else {
1066                Ok(Some(ratings.iter().sum::<f32>() / ratings.len() as f32))
1067            }
1068        }
1069    }
1070
1071    // ── Mock OwnerCreditBalanceRepository ────────────────────────────────
1072
1073    struct MockOwnerCreditBalanceRepository {
1074        balances: Mutex<HashMap<(Uuid, Uuid), OwnerCreditBalance>>,
1075    }
1076
1077    impl MockOwnerCreditBalanceRepository {
1078        fn new() -> Self {
1079            Self {
1080                balances: Mutex::new(HashMap::new()),
1081            }
1082        }
1083    }
1084
1085    #[async_trait]
1086    impl OwnerCreditBalanceRepository for MockOwnerCreditBalanceRepository {
1087        async fn create(&self, balance: &OwnerCreditBalance) -> Result<OwnerCreditBalance, String> {
1088            self.balances
1089                .lock()
1090                .unwrap()
1091                .insert((balance.owner_id, balance.building_id), balance.clone());
1092            Ok(balance.clone())
1093        }
1094
1095        async fn find_by_owner_and_building(
1096            &self,
1097            owner_id: Uuid,
1098            building_id: Uuid,
1099        ) -> Result<Option<OwnerCreditBalance>, String> {
1100            Ok(self
1101                .balances
1102                .lock()
1103                .unwrap()
1104                .get(&(owner_id, building_id))
1105                .cloned())
1106        }
1107
1108        async fn find_by_building(
1109            &self,
1110            building_id: Uuid,
1111        ) -> Result<Vec<OwnerCreditBalance>, String> {
1112            Ok(self
1113                .balances
1114                .lock()
1115                .unwrap()
1116                .values()
1117                .filter(|b| b.building_id == building_id)
1118                .cloned()
1119                .collect())
1120        }
1121
1122        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerCreditBalance>, String> {
1123            Ok(self
1124                .balances
1125                .lock()
1126                .unwrap()
1127                .values()
1128                .filter(|b| b.owner_id == owner_id)
1129                .cloned()
1130                .collect())
1131        }
1132
1133        async fn get_or_create(
1134            &self,
1135            owner_id: Uuid,
1136            building_id: Uuid,
1137        ) -> Result<OwnerCreditBalance, String> {
1138            let mut map = self.balances.lock().unwrap();
1139            if let Some(existing) = map.get(&(owner_id, building_id)) {
1140                Ok(existing.clone())
1141            } else {
1142                let balance = OwnerCreditBalance::new(owner_id, building_id);
1143                map.insert((owner_id, building_id), balance.clone());
1144                Ok(balance)
1145            }
1146        }
1147
1148        async fn update(&self, balance: &OwnerCreditBalance) -> Result<OwnerCreditBalance, String> {
1149            self.balances
1150                .lock()
1151                .unwrap()
1152                .insert((balance.owner_id, balance.building_id), balance.clone());
1153            Ok(balance.clone())
1154        }
1155
1156        async fn delete(&self, owner_id: Uuid, building_id: Uuid) -> Result<bool, String> {
1157            Ok(self
1158                .balances
1159                .lock()
1160                .unwrap()
1161                .remove(&(owner_id, building_id))
1162                .is_some())
1163        }
1164
1165        async fn get_leaderboard(
1166            &self,
1167            building_id: Uuid,
1168            limit: i32,
1169        ) -> Result<Vec<OwnerCreditBalance>, String> {
1170            let mut balances: Vec<_> = self
1171                .balances
1172                .lock()
1173                .unwrap()
1174                .values()
1175                .filter(|b| b.building_id == building_id)
1176                .cloned()
1177                .collect();
1178            balances.sort_by_key(|a| std::cmp::Reverse(a.balance));
1179            balances.truncate(limit as usize);
1180            Ok(balances)
1181        }
1182
1183        async fn count_active_participants(&self, building_id: Uuid) -> Result<i64, String> {
1184            Ok(self
1185                .balances
1186                .lock()
1187                .unwrap()
1188                .values()
1189                .filter(|b| b.building_id == building_id && b.total_exchanges > 0)
1190                .count() as i64)
1191        }
1192
1193        async fn get_total_credits_in_circulation(&self, building_id: Uuid) -> Result<i32, String> {
1194            Ok(self
1195                .balances
1196                .lock()
1197                .unwrap()
1198                .values()
1199                .filter(|b| b.building_id == building_id)
1200                .map(|b| b.credits_earned)
1201                .sum())
1202        }
1203    }
1204
1205    // ── Test Helpers ────────────────────────────────────────────────────
1206
1207    fn make_owner(user_id: Uuid) -> Owner {
1208        let now = Utc::now();
1209        Owner {
1210            id: Uuid::new_v4(),
1211            organization_id: Uuid::new_v4(),
1212            user_id: Some(user_id),
1213            first_name: "Jean".to_string(),
1214            last_name: "Dupont".to_string(),
1215            email: "jean@example.com".to_string(),
1216            phone: None,
1217            address: "1 rue de la Loi".to_string(),
1218            city: "Bruxelles".to_string(),
1219            postal_code: "1000".to_string(),
1220            country: "BE".to_string(),
1221            created_at: now,
1222            updated_at: now,
1223        }
1224    }
1225
1226    fn make_owner_with_name(user_id: Uuid, first: &str, last: &str) -> Owner {
1227        let now = Utc::now();
1228        Owner {
1229            id: Uuid::new_v4(),
1230            organization_id: Uuid::new_v4(),
1231            user_id: Some(user_id),
1232            first_name: first.to_string(),
1233            last_name: last.to_string(),
1234            email: format!("{}@example.com", first.to_lowercase()),
1235            phone: None,
1236            address: "1 rue de la Loi".to_string(),
1237            city: "Bruxelles".to_string(),
1238            postal_code: "1000".to_string(),
1239            country: "BE".to_string(),
1240            created_at: now,
1241            updated_at: now,
1242        }
1243    }
1244
1245    fn setup_use_cases(
1246        owner_repo: Arc<MockOwnerRepository>,
1247        exchange_repo: Arc<MockLocalExchangeRepository>,
1248        balance_repo: Arc<MockOwnerCreditBalanceRepository>,
1249    ) -> LocalExchangeUseCases {
1250        LocalExchangeUseCases::new(exchange_repo, balance_repo, owner_repo)
1251    }
1252
1253    // ── Tests ───────────────────────────────────────────────────────────
1254
1255    #[tokio::test]
1256    async fn test_create_exchange_success() {
1257        let owner_repo = Arc::new(MockOwnerRepository::new());
1258        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1259        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1260
1261        let user_id = Uuid::new_v4();
1262        let provider = make_owner_with_name(user_id, "Alice", "Martin");
1263        owner_repo.insert(provider.clone());
1264
1265        let building_id = Uuid::new_v4();
1266        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1267
1268        let dto = CreateLocalExchangeDto {
1269            building_id,
1270            exchange_type: ExchangeType::Service,
1271            title: "Gardening help".to_string(),
1272            description: "I can help with your garden".to_string(),
1273            credits: 3,
1274        };
1275
1276        let result = uc.create_exchange(user_id, dto).await;
1277        assert!(result.is_ok(), "create_exchange failed: {:?}", result.err());
1278
1279        let resp = result.unwrap();
1280        assert_eq!(resp.building_id, building_id);
1281        assert_eq!(resp.provider_id, provider.id);
1282        assert_eq!(resp.provider_name, "Alice Martin");
1283        assert_eq!(resp.status, ExchangeStatus::Offered);
1284        assert_eq!(resp.credits, 3);
1285        assert_eq!(resp.title, "Gardening help");
1286        assert!(resp.requester_id.is_none());
1287    }
1288
1289    #[tokio::test]
1290    async fn test_request_exchange_success() {
1291        let owner_repo = Arc::new(MockOwnerRepository::new());
1292        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1293        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1294
1295        let provider_user_id = Uuid::new_v4();
1296        let requester_user_id = Uuid::new_v4();
1297        let building_id = Uuid::new_v4();
1298
1299        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1300        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1301        owner_repo.insert(provider.clone());
1302        owner_repo.insert(requester.clone());
1303
1304        let uc = setup_use_cases(owner_repo, exchange_repo.clone(), balance_repo);
1305
1306        // Create an exchange first
1307        let dto = CreateLocalExchangeDto {
1308            building_id,
1309            exchange_type: ExchangeType::ObjectLoan,
1310            title: "Drill to lend".to_string(),
1311            description: "Heavy-duty drill available".to_string(),
1312            credits: 1,
1313        };
1314        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1315
1316        // Now requester requests it
1317        let result = uc
1318            .request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1319            .await;
1320        assert!(
1321            result.is_ok(),
1322            "request_exchange failed: {:?}",
1323            result.err()
1324        );
1325
1326        let resp = result.unwrap();
1327        assert_eq!(resp.status, ExchangeStatus::Requested);
1328        assert_eq!(resp.requester_id, Some(requester.id));
1329        assert_eq!(resp.requester_name, Some("Bob Leroy".to_string()));
1330    }
1331
1332    #[tokio::test]
1333    async fn test_start_exchange_success() {
1334        let owner_repo = Arc::new(MockOwnerRepository::new());
1335        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1336        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1337
1338        let provider_user_id = Uuid::new_v4();
1339        let requester_user_id = Uuid::new_v4();
1340        let building_id = Uuid::new_v4();
1341
1342        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1343        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1344        owner_repo.insert(provider.clone());
1345        owner_repo.insert(requester.clone());
1346
1347        let uc = setup_use_cases(owner_repo, exchange_repo.clone(), balance_repo);
1348
1349        // Create + request
1350        let dto = CreateLocalExchangeDto {
1351            building_id,
1352            exchange_type: ExchangeType::Service,
1353            title: "Plumbing fix".to_string(),
1354            description: "Fix leaking pipe".to_string(),
1355            credits: 2,
1356        };
1357        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1358        uc.request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1359            .await
1360            .unwrap();
1361
1362        // Provider starts the exchange
1363        let result = uc.start_exchange(created.id, provider_user_id).await;
1364        assert!(result.is_ok(), "start_exchange failed: {:?}", result.err());
1365
1366        let resp = result.unwrap();
1367        assert_eq!(resp.status, ExchangeStatus::InProgress);
1368        assert!(resp.started_at.is_some());
1369    }
1370
1371    #[tokio::test]
1372    async fn test_complete_exchange_updates_credit_balances() {
1373        let owner_repo = Arc::new(MockOwnerRepository::new());
1374        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1375        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1376
1377        let provider_user_id = Uuid::new_v4();
1378        let requester_user_id = Uuid::new_v4();
1379        let building_id = Uuid::new_v4();
1380
1381        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1382        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1383        owner_repo.insert(provider.clone());
1384        owner_repo.insert(requester.clone());
1385
1386        let uc = setup_use_cases(
1387            owner_repo.clone(),
1388            exchange_repo.clone(),
1389            balance_repo.clone(),
1390        );
1391
1392        // Create + request + start
1393        let credits = 5;
1394        let dto = CreateLocalExchangeDto {
1395            building_id,
1396            exchange_type: ExchangeType::Service,
1397            title: "IT Help".to_string(),
1398            description: "Install software".to_string(),
1399            credits,
1400        };
1401        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1402        uc.request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1403            .await
1404            .unwrap();
1405        uc.start_exchange(created.id, provider_user_id)
1406            .await
1407            .unwrap();
1408
1409        // Provider completes the exchange
1410        let result = uc
1411            .complete_exchange(created.id, provider_user_id, CompleteExchangeDto {})
1412            .await;
1413        assert!(
1414            result.is_ok(),
1415            "complete_exchange failed: {:?}",
1416            result.err()
1417        );
1418
1419        let resp = result.unwrap();
1420        assert_eq!(resp.status, ExchangeStatus::Completed);
1421        assert!(resp.completed_at.is_some());
1422
1423        // Verify credit balances were updated
1424        let provider_balance = uc
1425            .get_credit_balance(provider.id, building_id)
1426            .await
1427            .unwrap();
1428        assert_eq!(provider_balance.credits_earned, credits);
1429        assert_eq!(provider_balance.balance, credits);
1430        assert_eq!(provider_balance.total_exchanges, 1);
1431
1432        let requester_balance = uc
1433            .get_credit_balance(requester.id, building_id)
1434            .await
1435            .unwrap();
1436        assert_eq!(requester_balance.credits_spent, credits);
1437        assert_eq!(requester_balance.balance, -credits);
1438        assert_eq!(requester_balance.total_exchanges, 1);
1439    }
1440
1441    #[tokio::test]
1442    async fn test_cancel_exchange_success() {
1443        let owner_repo = Arc::new(MockOwnerRepository::new());
1444        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1445        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1446
1447        let provider_user_id = Uuid::new_v4();
1448        let requester_user_id = Uuid::new_v4();
1449        let building_id = Uuid::new_v4();
1450
1451        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1452        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1453        owner_repo.insert(provider.clone());
1454        owner_repo.insert(requester.clone());
1455
1456        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1457
1458        // Create + request
1459        let dto = CreateLocalExchangeDto {
1460            building_id,
1461            exchange_type: ExchangeType::SharedPurchase,
1462            title: "Bulk order".to_string(),
1463            description: "Shared cleaning supplies".to_string(),
1464            credits: 2,
1465        };
1466        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1467        uc.request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1468            .await
1469            .unwrap();
1470
1471        // Requester cancels with reason
1472        let cancel_dto = CancelExchangeDto {
1473            reason: Some("Changed my mind".to_string()),
1474        };
1475        let result = uc
1476            .cancel_exchange(created.id, requester_user_id, "owner", cancel_dto)
1477            .await;
1478        assert!(result.is_ok(), "cancel_exchange failed: {:?}", result.err());
1479
1480        let resp = result.unwrap();
1481        assert_eq!(resp.status, ExchangeStatus::Cancelled);
1482        assert!(resp.cancelled_at.is_some());
1483        assert_eq!(
1484            resp.cancellation_reason,
1485            Some("Changed my mind".to_string())
1486        );
1487    }
1488
1489    // ------------------------------------------------------------------------
1490    // Story 5.3 (#587), INV-4 — Syndic = community.moderator sur le SEL
1491    // ------------------------------------------------------------------------
1492
1493    #[tokio::test]
1494    async fn security_syndic_pur_ne_peut_pas_creer_une_offre_sel() {
1495        // Syndic sans fiche de copropriétaire dans cette organisation : aucun
1496        // `Owner` n'est inséré pour ce `user_id`. `create_exchange` (l'acte de
1497        // PARTICIPATION personnelle) doit rester refusé — INV-4.
1498        let owner_repo = Arc::new(MockOwnerRepository::new());
1499        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1500        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1501        let syndic_user_id = Uuid::new_v4();
1502        let building_id = Uuid::new_v4();
1503
1504        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1505
1506        let dto = CreateLocalExchangeDto {
1507            building_id,
1508            exchange_type: ExchangeType::Service,
1509            title: "Coup de main".to_string(),
1510            description: "Offre personnelle".to_string(),
1511            credits: 1,
1512        };
1513
1514        let result = uc.create_exchange(syndic_user_id, dto).await;
1515        assert!(result.is_err());
1516        assert_eq!(
1517            result.unwrap_err(),
1518            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES
1519        );
1520    }
1521
1522    #[tokio::test]
1523    async fn happy_syndic_moderator_annule_un_echange_litigieux_avec_motif() {
1524        let owner_repo = Arc::new(MockOwnerRepository::new());
1525        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1526        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1527
1528        let provider_user_id = Uuid::new_v4();
1529        let syndic_user_id = Uuid::new_v4(); // aucune fiche Owner
1530        let building_id = Uuid::new_v4();
1531
1532        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1533        owner_repo.insert(provider.clone());
1534
1535        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1536
1537        let dto = CreateLocalExchangeDto {
1538            building_id,
1539            exchange_type: ExchangeType::Service,
1540            title: "Bricolage".to_string(),
1541            description: "Offre litigieuse".to_string(),
1542            credits: 3,
1543        };
1544        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1545
1546        let cancel_dto = CancelExchangeDto {
1547            reason: Some("Signalement d'un voisin : offre inappropriée".to_string()),
1548        };
1549        let result = uc
1550            .cancel_exchange(created.id, syndic_user_id, "syndic", cancel_dto)
1551            .await;
1552        assert!(
1553            result.is_ok(),
1554            "un syndic modérateur doit pouvoir annuler un échange litigieux : {:?}",
1555            result.err()
1556        );
1557        assert_eq!(result.unwrap().status, ExchangeStatus::Cancelled);
1558    }
1559
1560    #[tokio::test]
1561    async fn negative_moderation_sans_motif_est_refusee() {
1562        let owner_repo = Arc::new(MockOwnerRepository::new());
1563        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1564        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1565
1566        let provider_user_id = Uuid::new_v4();
1567        let syndic_user_id = Uuid::new_v4();
1568        let building_id = Uuid::new_v4();
1569
1570        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1571        owner_repo.insert(provider.clone());
1572
1573        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1574
1575        let dto = CreateLocalExchangeDto {
1576            building_id,
1577            exchange_type: ExchangeType::Service,
1578            title: "Bricolage".to_string(),
1579            description: "Offre".to_string(),
1580            credits: 3,
1581        };
1582        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1583
1584        // Motif absent : une modération non motivée n'est pas auditable.
1585        let cancel_dto = CancelExchangeDto { reason: None };
1586        let result = uc
1587            .cancel_exchange(created.id, syndic_user_id, "syndic", cancel_dto)
1588            .await;
1589        assert!(result.is_err());
1590        assert_eq!(
1591            result.unwrap_err(),
1592            crate::application::error::MOTIF_MODERATION_REQUIS
1593        );
1594
1595        // Motif vide (espaces uniquement) : même refus, pas de contournement.
1596        let cancel_dto_blank = CancelExchangeDto {
1597            reason: Some("   ".to_string()),
1598        };
1599        let result_blank = uc
1600            .cancel_exchange(created.id, syndic_user_id, "syndic", cancel_dto_blank)
1601            .await;
1602        assert!(result_blank.is_err());
1603        assert_eq!(
1604            result_blank.unwrap_err(),
1605            crate::application::error::MOTIF_MODERATION_REQUIS
1606        );
1607    }
1608
1609    #[tokio::test]
1610    async fn security_ni_partie_ni_moderateur_ne_peut_annuler() {
1611        let owner_repo = Arc::new(MockOwnerRepository::new());
1612        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1613        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1614
1615        let provider_user_id = Uuid::new_v4();
1616        let bystander_user_id = Uuid::new_v4(); // un autre copropriétaire, non partie
1617        let building_id = Uuid::new_v4();
1618
1619        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1620        let bystander = make_owner_with_name(bystander_user_id, "Carl", "Voisin");
1621        owner_repo.insert(provider.clone());
1622        owner_repo.insert(bystander.clone());
1623
1624        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1625
1626        let dto = CreateLocalExchangeDto {
1627            building_id,
1628            exchange_type: ExchangeType::Service,
1629            title: "Bricolage".to_string(),
1630            description: "Offre".to_string(),
1631            credits: 3,
1632        };
1633        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1634
1635        let cancel_dto = CancelExchangeDto {
1636            reason: Some("Je n'aime pas cette offre".to_string()),
1637        };
1638        let result = uc
1639            .cancel_exchange(created.id, bystander_user_id, "owner", cancel_dto)
1640            .await;
1641        assert!(result.is_err());
1642        assert!(result
1643            .unwrap_err()
1644            .contains("Only the provider, requester, or a community moderator"));
1645    }
1646
1647    #[tokio::test]
1648    async fn edge_syndic_cumulant_le_role_owner_annule_son_propre_echange_sans_motif() {
1649        // Le syndic a AUSSI une fiche de copropriétaire dans cette ACP (un
1650        // lot) : il agit ès qualités de copropriétaire, comme n'importe quel
1651        // provider — pas de motif requis, comportement inchangé. Le droit
1652        // vient du lot, pas de la fonction (ADR-0052).
1653        let owner_repo = Arc::new(MockOwnerRepository::new());
1654        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1655        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1656
1657        let syndic_owner_user_id = Uuid::new_v4();
1658        let building_id = Uuid::new_v4();
1659
1660        let syndic_as_owner = make_owner_with_name(syndic_owner_user_id, "Denise", "Syndic");
1661        owner_repo.insert(syndic_as_owner.clone());
1662
1663        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1664
1665        let dto = CreateLocalExchangeDto {
1666            building_id,
1667            exchange_type: ExchangeType::Service,
1668            title: "Offre du syndic-copropriétaire".to_string(),
1669            description: "Offre personnelle, ès qualités de copropriétaire".to_string(),
1670            credits: 1,
1671        };
1672        let created = uc.create_exchange(syndic_owner_user_id, dto).await.expect(
1673            "un syndic qui a un lot doit pouvoir créer une offre ès qualités de copropriétaire",
1674        );
1675
1676        let cancel_dto = CancelExchangeDto { reason: None };
1677        let result = uc
1678            .cancel_exchange(created.id, syndic_owner_user_id, "syndic", cancel_dto)
1679            .await;
1680        assert!(
1681            result.is_ok(),
1682            "le provider n'a pas à motiver l'annulation de sa propre offre : {:?}",
1683            result.err()
1684        );
1685        assert_eq!(result.unwrap().status, ExchangeStatus::Cancelled);
1686    }
1687
1688    #[tokio::test]
1689    async fn happy_syndic_moderator_supprime_un_echange_litigieux_avec_motif() {
1690        let owner_repo = Arc::new(MockOwnerRepository::new());
1691        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1692        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1693
1694        let provider_user_id = Uuid::new_v4();
1695        let syndic_user_id = Uuid::new_v4();
1696        let building_id = Uuid::new_v4();
1697
1698        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1699        owner_repo.insert(provider.clone());
1700
1701        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1702
1703        let dto = CreateLocalExchangeDto {
1704            building_id,
1705            exchange_type: ExchangeType::Service,
1706            title: "Bricolage".to_string(),
1707            description: "Offre litigieuse".to_string(),
1708            credits: 3,
1709        };
1710        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1711
1712        let result = uc
1713            .delete_exchange(
1714                created.id,
1715                syndic_user_id,
1716                "syndic",
1717                Some("Contenu inapproprié signalé".to_string()),
1718            )
1719            .await;
1720        assert!(
1721            result.is_ok(),
1722            "un syndic modérateur doit pouvoir supprimer une offre litigieuse : {:?}",
1723            result.err()
1724        );
1725        assert!(uc.get_exchange(created.id).await.is_err());
1726    }
1727
1728    #[tokio::test]
1729    async fn negative_suppression_par_moderation_sans_motif_est_refusee() {
1730        let owner_repo = Arc::new(MockOwnerRepository::new());
1731        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1732        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1733
1734        let provider_user_id = Uuid::new_v4();
1735        let syndic_user_id = Uuid::new_v4();
1736        let building_id = Uuid::new_v4();
1737
1738        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1739        owner_repo.insert(provider.clone());
1740
1741        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1742
1743        let dto = CreateLocalExchangeDto {
1744            building_id,
1745            exchange_type: ExchangeType::Service,
1746            title: "Bricolage".to_string(),
1747            description: "Offre".to_string(),
1748            credits: 3,
1749        };
1750        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1751
1752        let result = uc
1753            .delete_exchange(created.id, syndic_user_id, "syndic", None)
1754            .await;
1755        assert!(result.is_err());
1756        assert_eq!(
1757            result.unwrap_err(),
1758            crate::application::error::MOTIF_MODERATION_REQUIS
1759        );
1760    }
1761
1762    #[tokio::test]
1763    async fn security_ni_provider_ni_moderateur_ne_peut_supprimer() {
1764        let owner_repo = Arc::new(MockOwnerRepository::new());
1765        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1766        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1767
1768        let provider_user_id = Uuid::new_v4();
1769        let bystander_user_id = Uuid::new_v4();
1770        let building_id = Uuid::new_v4();
1771
1772        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1773        let bystander = make_owner_with_name(bystander_user_id, "Carl", "Voisin");
1774        owner_repo.insert(provider.clone());
1775        owner_repo.insert(bystander.clone());
1776
1777        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1778
1779        let dto = CreateLocalExchangeDto {
1780            building_id,
1781            exchange_type: ExchangeType::Service,
1782            title: "Bricolage".to_string(),
1783            description: "Offre".to_string(),
1784            credits: 3,
1785        };
1786        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1787
1788        let result = uc
1789            .delete_exchange(created.id, bystander_user_id, "owner", None)
1790            .await;
1791        assert!(result.is_err());
1792        assert!(result
1793            .unwrap_err()
1794            .contains("Only the provider or a community moderator"));
1795    }
1796
1797    #[tokio::test]
1798    async fn test_rate_provider_success() {
1799        let owner_repo = Arc::new(MockOwnerRepository::new());
1800        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1801        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1802
1803        let provider_user_id = Uuid::new_v4();
1804        let requester_user_id = Uuid::new_v4();
1805        let building_id = Uuid::new_v4();
1806
1807        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1808        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1809        owner_repo.insert(provider.clone());
1810        owner_repo.insert(requester.clone());
1811
1812        let uc = setup_use_cases(owner_repo, exchange_repo.clone(), balance_repo.clone());
1813
1814        // Full workflow: create + request + start + complete
1815        let dto = CreateLocalExchangeDto {
1816            building_id,
1817            exchange_type: ExchangeType::Service,
1818            title: "Painting".to_string(),
1819            description: "Paint the hallway".to_string(),
1820            credits: 4,
1821        };
1822        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1823        uc.request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1824            .await
1825            .unwrap();
1826        uc.start_exchange(created.id, provider_user_id)
1827            .await
1828            .unwrap();
1829        uc.complete_exchange(created.id, provider_user_id, CompleteExchangeDto {})
1830            .await
1831            .unwrap();
1832
1833        // Requester rates provider
1834        let rate_dto = RateExchangeDto { rating: 5 };
1835        let result = uc
1836            .rate_provider(created.id, requester_user_id, rate_dto)
1837            .await;
1838        assert!(result.is_ok(), "rate_provider failed: {:?}", result.err());
1839
1840        let resp = result.unwrap();
1841        assert_eq!(resp.provider_rating, Some(5));
1842
1843        // Verify provider's average rating was updated in balance
1844        let provider_balance = balance_repo
1845            .find_by_owner_and_building(provider.id, building_id)
1846            .await
1847            .unwrap()
1848            .unwrap();
1849        assert_eq!(provider_balance.average_rating, Some(5.0));
1850    }
1851
1852    #[tokio::test]
1853    async fn test_rate_requester_success() {
1854        let owner_repo = Arc::new(MockOwnerRepository::new());
1855        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1856        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1857
1858        let provider_user_id = Uuid::new_v4();
1859        let requester_user_id = Uuid::new_v4();
1860        let building_id = Uuid::new_v4();
1861
1862        let provider = make_owner_with_name(provider_user_id, "Alice", "Martin");
1863        let requester = make_owner_with_name(requester_user_id, "Bob", "Leroy");
1864        owner_repo.insert(provider.clone());
1865        owner_repo.insert(requester.clone());
1866
1867        let uc = setup_use_cases(owner_repo, exchange_repo.clone(), balance_repo.clone());
1868
1869        // Full workflow: create + request + start + complete
1870        let dto = CreateLocalExchangeDto {
1871            building_id,
1872            exchange_type: ExchangeType::Service,
1873            title: "Babysitting".to_string(),
1874            description: "Watch kids for 3 hours".to_string(),
1875            credits: 3,
1876        };
1877        let created = uc.create_exchange(provider_user_id, dto).await.unwrap();
1878        uc.request_exchange(created.id, requester_user_id, RequestExchangeDto {})
1879            .await
1880            .unwrap();
1881        uc.start_exchange(created.id, provider_user_id)
1882            .await
1883            .unwrap();
1884        uc.complete_exchange(created.id, provider_user_id, CompleteExchangeDto {})
1885            .await
1886            .unwrap();
1887
1888        // Provider rates requester
1889        let rate_dto = RateExchangeDto { rating: 4 };
1890        let result = uc
1891            .rate_requester(created.id, provider_user_id, rate_dto)
1892            .await;
1893        assert!(result.is_ok(), "rate_requester failed: {:?}", result.err());
1894
1895        let resp = result.unwrap();
1896        assert_eq!(resp.requester_rating, Some(4));
1897
1898        // Verify requester's average rating was updated
1899        let requester_balance = balance_repo
1900            .find_by_owner_and_building(requester.id, building_id)
1901            .await
1902            .unwrap()
1903            .unwrap();
1904        assert_eq!(requester_balance.average_rating, Some(4.0));
1905    }
1906
1907    #[tokio::test]
1908    async fn test_get_credit_balance_creates_if_missing() {
1909        let owner_repo = Arc::new(MockOwnerRepository::new());
1910        let exchange_repo = Arc::new(MockLocalExchangeRepository::new());
1911        let balance_repo = Arc::new(MockOwnerCreditBalanceRepository::new());
1912
1913        let user_id = Uuid::new_v4();
1914        let owner = make_owner(user_id);
1915        let owner_id = owner.id;
1916        let building_id = Uuid::new_v4();
1917        owner_repo.insert(owner);
1918
1919        let uc = setup_use_cases(owner_repo, exchange_repo, balance_repo);
1920
1921        // No balance exists yet; get_credit_balance should auto-create via get_or_create
1922        let result = uc.get_credit_balance(owner_id, building_id).await;
1923        assert!(
1924            result.is_ok(),
1925            "get_credit_balance failed: {:?}",
1926            result.err()
1927        );
1928
1929        let balance = result.unwrap();
1930        assert_eq!(balance.owner_id, owner_id);
1931        assert_eq!(balance.building_id, building_id);
1932        assert_eq!(balance.credits_earned, 0);
1933        assert_eq!(balance.credits_spent, 0);
1934        assert_eq!(balance.balance, 0);
1935        assert_eq!(balance.total_exchanges, 0);
1936    }
1937}