Skip to main content

koprogo_api/application/use_cases/
resource_booking_use_cases.rs

1use crate::application::dto::{
2    BookingStatisticsDto, CreateResourceBookingDto, ResourceBookingResponseDto,
3    UpdateResourceBookingDto,
4};
5use crate::application::ports::{OwnerRepository, ResourceBookingRepository};
6use crate::domain::entities::{BookingStatus, ResourceBooking, ResourceType};
7use chrono::Utc;
8use std::sync::Arc;
9use uuid::Uuid;
10
11/// Use cases for resource booking operations
12///
13/// Orchestrates business logic for community space bookings with conflict detection,
14/// owner name enrichment, and authorization checks.
15pub struct ResourceBookingUseCases {
16    booking_repo: Arc<dyn ResourceBookingRepository>,
17    owner_repo: Arc<dyn OwnerRepository>,
18}
19
20impl ResourceBookingUseCases {
21    pub fn new(
22        booking_repo: Arc<dyn ResourceBookingRepository>,
23        owner_repo: Arc<dyn OwnerRepository>,
24    ) -> Self {
25        Self {
26            booking_repo,
27            owner_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 syndic agissant pour le compte de l'ACP (AG, prestataires)
54            // ne passe pas par ce chemin : voir `dto.on_behalf_of_acp` et
55            // `create_booking_on_behalf_of_acp()` (story #588).
56            .ok_or_else(|| crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES.to_string())
57    }
58
59    /// Create a new resource booking with conflict detection
60    ///
61    /// # Steps
62    /// 1. Create booking entity (validates business rules)
63    /// 2. Check for conflicts (overlapping bookings for same resource)
64    /// 3. Persist booking
65    /// 4. Enrich response with owner name
66    ///
67    /// # Authorization
68    /// - Any authenticated owner can create bookings for themselves.
69    /// - Story #588 (INV-5/FR27) — `dto.on_behalf_of_acp = true` books on
70    ///   behalf of the ACP (AG, prestataires) instead of personally. Reserved
71    ///   to `is_syndic` callers (403 otherwise) and requires `dto.motif`
72    ///   (422 otherwise, via `ReservationOnBehalfError`). A syndic without an
73    ///   owner profile who omits `on_behalf_of_acp` still hits the ordinary
74    ///   `resolve_owner()` refusal below (INV-5 "no personal participation").
75    pub async fn create_booking(
76        &self,
77        user_id: Uuid,
78        organization_id: Uuid,
79        is_syndic: bool,
80        dto: CreateResourceBookingDto,
81    ) -> Result<ResourceBookingResponseDto, String> {
82        if dto.on_behalf_of_acp {
83            return self
84                .create_booking_on_behalf_of_acp(user_id, is_syndic, dto)
85                .await;
86        }
87
88        let owner = self.resolve_owner(user_id, organization_id).await?;
89        let booked_by = owner.id;
90        // Create booking entity (validates business rules in constructor)
91        let booking = ResourceBooking::new(
92            dto.building_id,
93            dto.resource_type.clone(),
94            dto.resource_name.clone(),
95            booked_by,
96            dto.start_time,
97            dto.end_time,
98            dto.notes.clone(),
99            dto.recurring_pattern.clone(),
100            dto.recurrence_end_date,
101            dto.max_duration_hours,
102            dto.max_advance_days,
103        )?;
104
105        // Check for conflicts (overlapping bookings for same resource)
106        let conflicts = self
107            .booking_repo
108            .find_conflicts(
109                dto.building_id,
110                dto.resource_type,
111                &dto.resource_name,
112                dto.start_time,
113                dto.end_time,
114                None, // No exclusion for new bookings
115            )
116            .await?;
117
118        if !conflicts.is_empty() {
119            return Err(format!(
120                "Booking conflicts with {} existing booking(s) for this resource",
121                conflicts.len()
122            ));
123        }
124
125        // Persist booking
126        let created = self.booking_repo.create(&booking).await?;
127
128        // Enrich with owner name
129        self.enrich_booking_response(created).await
130    }
131
132    /// Story #588 (INV-5/FR27) — réservation syndic "pour le compte de l'ACP".
133    ///
134    /// Séparée de `create_booking()` pour ne pas mêler deux légitimités
135    /// différentes : celle d'un copropriétaire (fiche `Owner`) et celle d'un
136    /// syndic agissant ès qualités (pas de fiche `Owner`, motif obligatoire).
137    async fn create_booking_on_behalf_of_acp(
138        &self,
139        user_id: Uuid,
140        is_syndic: bool,
141        dto: CreateResourceBookingDto,
142    ) -> Result<ResourceBookingResponseDto, String> {
143        if !is_syndic {
144            return Err(crate::application::error::REFUS_ON_BEHALF_RESERVE_AUX_SYNDICS.to_string());
145        }
146
147        let motif = dto.motif.clone().unwrap_or_default();
148        let booking = ResourceBooking::new_on_behalf_of_acp(
149            dto.building_id,
150            dto.resource_type.clone(),
151            dto.resource_name.clone(),
152            user_id,
153            motif,
154            dto.start_time,
155            dto.end_time,
156            dto.notes.clone(),
157            dto.recurring_pattern.clone(),
158            dto.recurrence_end_date,
159            dto.max_duration_hours,
160            dto.max_advance_days,
161        )?;
162
163        let conflicts = self
164            .booking_repo
165            .find_conflicts(
166                dto.building_id,
167                dto.resource_type,
168                &dto.resource_name,
169                dto.start_time,
170                dto.end_time,
171                None,
172            )
173            .await?;
174
175        if !conflicts.is_empty() {
176            return Err(format!(
177                "Booking conflicts with {} existing booking(s) for this resource",
178                conflicts.len()
179            ));
180        }
181
182        let created = self.booking_repo.create(&booking).await?;
183
184        // AC @happy — "log spécifique" : distingue à l'audit une réservation
185        // pour le compte de l'ACP d'une réservation personnelle, sans devoir
186        // rouvrir la base pour le savoir.
187        log::info!(
188            "reservation_on_behalf_acp: syndic_user_id={} building_id={} resource=\"{}\" motif=\"{}\"",
189            user_id,
190            dto.building_id,
191            dto.resource_name,
192            created.motif.clone().unwrap_or_default(),
193        );
194
195        self.enrich_booking_response(created).await
196    }
197
198    /// Get booking by ID with owner name enrichment
199    pub async fn get_booking(
200        &self,
201        booking_id: Uuid,
202    ) -> Result<ResourceBookingResponseDto, String> {
203        let booking = self
204            .booking_repo
205            .find_by_id(booking_id)
206            .await?
207            .ok_or("Booking not found".to_string())?;
208
209        self.enrich_booking_response(booking).await
210    }
211
212    /// List all bookings for a building
213    pub async fn list_building_bookings(
214        &self,
215        building_id: Uuid,
216    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
217        let bookings = self.booking_repo.find_by_building(building_id).await?;
218        self.enrich_bookings_response(bookings).await
219    }
220
221    /// List bookings by building and resource type
222    pub async fn list_by_resource_type(
223        &self,
224        building_id: Uuid,
225        resource_type: ResourceType,
226    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
227        let bookings = self
228            .booking_repo
229            .find_by_building_and_resource_type(building_id, resource_type)
230            .await?;
231        self.enrich_bookings_response(bookings).await
232    }
233
234    /// List bookings for a specific resource (building + type + name)
235    pub async fn list_by_resource(
236        &self,
237        building_id: Uuid,
238        resource_type: ResourceType,
239        resource_name: String,
240    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
241        let bookings = self
242            .booking_repo
243            .find_by_resource(building_id, resource_type, &resource_name)
244            .await?;
245        self.enrich_bookings_response(bookings).await
246    }
247
248    /// List bookings by user (owner who made the booking)
249    pub async fn list_user_bookings(
250        &self,
251        user_id: Uuid,
252        organization_id: Uuid,
253    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
254        let owner = self.resolve_owner(user_id, organization_id).await?;
255        let bookings = self.booking_repo.find_by_user(owner.id).await?;
256        self.enrich_bookings_response(bookings).await
257    }
258
259    /// List bookings by user and status
260    pub async fn list_user_bookings_by_status(
261        &self,
262        user_id: Uuid,
263        organization_id: Uuid,
264        status: BookingStatus,
265    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
266        let owner = self.resolve_owner(user_id, organization_id).await?;
267        let bookings = self
268            .booking_repo
269            .find_by_user_and_status(owner.id, status)
270            .await?;
271        self.enrich_bookings_response(bookings).await
272    }
273
274    /// List bookings by building and status
275    pub async fn list_building_bookings_by_status(
276        &self,
277        building_id: Uuid,
278        status: BookingStatus,
279    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
280        let bookings = self
281            .booking_repo
282            .find_by_building_and_status(building_id, status)
283            .await?;
284        self.enrich_bookings_response(bookings).await
285    }
286
287    /// List upcoming bookings (future, confirmed or pending)
288    pub async fn list_upcoming_bookings(
289        &self,
290        building_id: Uuid,
291        limit: Option<i64>,
292    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
293        let bookings = self.booking_repo.find_upcoming(building_id, limit).await?;
294        self.enrich_bookings_response(bookings).await
295    }
296
297    /// List active bookings (currently in progress)
298    pub async fn list_active_bookings(
299        &self,
300        building_id: Uuid,
301    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
302        let bookings = self.booking_repo.find_active(building_id).await?;
303        self.enrich_bookings_response(bookings).await
304    }
305
306    /// List past bookings
307    pub async fn list_past_bookings(
308        &self,
309        building_id: Uuid,
310        limit: Option<i64>,
311    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
312        let bookings = self.booking_repo.find_past(building_id, limit).await?;
313        self.enrich_bookings_response(bookings).await
314    }
315
316    /// Update booking details (resource_name, notes)
317    ///
318    /// Time changes require cancellation and rebooking to ensure conflict detection.
319    ///
320    /// # Authorization
321    /// - Only booking owner can update their booking
322    pub async fn update_booking(
323        &self,
324        booking_id: Uuid,
325        user_id: Uuid,
326        organization_id: Uuid,
327        dto: UpdateResourceBookingDto,
328    ) -> Result<ResourceBookingResponseDto, String> {
329        let owner = self.resolve_owner(user_id, organization_id).await?;
330        let mut booking = self
331            .booking_repo
332            .find_by_id(booking_id)
333            .await?
334            .ok_or("Booking not found".to_string())?;
335
336        // Authorization: Only booking owner can update
337        if booking.booked_by != Some(owner.id) {
338            return Err("Only the booking owner can update this booking".to_string());
339        }
340
341        // Update details (validates business rules)
342        booking.update_details(dto.resource_name, dto.notes)?;
343
344        // Persist changes
345        let updated = self.booking_repo.update(&booking).await?;
346
347        // Enrich with owner name
348        self.enrich_booking_response(updated).await
349    }
350
351    /// Cancel a booking
352    ///
353    /// # Authorization
354    /// - Only booking owner can cancel their booking
355    pub async fn cancel_booking(
356        &self,
357        booking_id: Uuid,
358        user_id: Uuid,
359        organization_id: Uuid,
360    ) -> Result<ResourceBookingResponseDto, String> {
361        let owner = self.resolve_owner(user_id, organization_id).await?;
362        let mut booking = self
363            .booking_repo
364            .find_by_id(booking_id)
365            .await?
366            .ok_or("Booking not found".to_string())?;
367
368        // Cancel booking (validates authorization and state)
369        booking.cancel(owner.id)?;
370
371        // Persist changes
372        let updated = self.booking_repo.update(&booking).await?;
373
374        // Enrich with owner name
375        self.enrich_booking_response(updated).await
376    }
377
378    /// Complete a booking (typically auto-called after end_time)
379    ///
380    /// # Authorization
381    /// - Admin only (for manual completion)
382    pub async fn complete_booking(
383        &self,
384        booking_id: Uuid,
385    ) -> Result<ResourceBookingResponseDto, String> {
386        let mut booking = self
387            .booking_repo
388            .find_by_id(booking_id)
389            .await?
390            .ok_or("Booking not found".to_string())?;
391
392        // Complete booking (validates state)
393        booking.complete()?;
394
395        // Persist changes
396        let updated = self.booking_repo.update(&booking).await?;
397
398        // Enrich with owner name
399        self.enrich_booking_response(updated).await
400    }
401
402    /// Mark booking as no-show
403    ///
404    /// # Authorization
405    /// - Admin only
406    pub async fn mark_no_show(
407        &self,
408        booking_id: Uuid,
409    ) -> Result<ResourceBookingResponseDto, String> {
410        let mut booking = self
411            .booking_repo
412            .find_by_id(booking_id)
413            .await?
414            .ok_or("Booking not found".to_string())?;
415
416        // Mark as no-show (validates state)
417        booking.mark_no_show()?;
418
419        // Persist changes
420        let updated = self.booking_repo.update(&booking).await?;
421
422        // Enrich with owner name
423        self.enrich_booking_response(updated).await
424    }
425
426    /// Confirm a pending booking
427    ///
428    /// # Authorization
429    /// - Admin only (for approval workflow)
430    pub async fn confirm_booking(
431        &self,
432        booking_id: Uuid,
433    ) -> Result<ResourceBookingResponseDto, String> {
434        let mut booking = self
435            .booking_repo
436            .find_by_id(booking_id)
437            .await?
438            .ok_or("Booking not found".to_string())?;
439
440        // Confirm booking (validates state)
441        booking.confirm()?;
442
443        // Persist changes
444        let updated = self.booking_repo.update(&booking).await?;
445
446        // Enrich with owner name
447        self.enrich_booking_response(updated).await
448    }
449
450    /// Delete a booking
451    ///
452    /// # Authorization
453    /// - Only booking owner can delete their booking
454    /// - Or admin for any booking
455    pub async fn delete_booking(
456        &self,
457        booking_id: Uuid,
458        user_id: Uuid,
459        organization_id: Uuid,
460    ) -> Result<(), String> {
461        let owner = self.resolve_owner(user_id, organization_id).await?;
462        let booking = self
463            .booking_repo
464            .find_by_id(booking_id)
465            .await?
466            .ok_or("Booking not found".to_string())?;
467
468        // Authorization: Only booking owner can delete
469        if booking.booked_by != Some(owner.id) {
470            return Err("Only the booking owner can delete this booking".to_string());
471        }
472
473        self.booking_repo.delete(booking_id).await
474    }
475
476    /// Check for booking conflicts
477    ///
478    /// Useful for frontend to preview conflicts before creating booking.
479    ///
480    /// Returns list of conflicting bookings with owner names.
481    pub async fn check_conflicts(
482        &self,
483        building_id: Uuid,
484        resource_type: ResourceType,
485        resource_name: String,
486        start_time: chrono::DateTime<Utc>,
487        end_time: chrono::DateTime<Utc>,
488        exclude_booking_id: Option<Uuid>,
489    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
490        let conflicts = self
491            .booking_repo
492            .find_conflicts(
493                building_id,
494                resource_type,
495                &resource_name,
496                start_time,
497                end_time,
498                exclude_booking_id,
499            )
500            .await?;
501
502        self.enrich_bookings_response(conflicts).await
503    }
504
505    /// Get booking statistics for a building
506    pub async fn get_statistics(&self, building_id: Uuid) -> Result<BookingStatisticsDto, String> {
507        self.booking_repo.get_statistics(building_id).await
508    }
509
510    /// Helper: Enrich single booking with owner name
511    ///
512    /// Story #588 — une réservation `on_behalf_of_acp` n'a pas d'owner (le
513    /// syndic n'en a structurellement pas) : elle est étiquetée par un
514    /// libellé fixe plutôt que par une recherche d'owner qui échouerait
515    /// toujours.
516    async fn enrich_booking_response(
517        &self,
518        booking: ResourceBooking,
519    ) -> Result<ResourceBookingResponseDto, String> {
520        let booked_by_name = if booking.on_behalf_of_acp {
521            "Syndic — pour le compte de l'ACP".to_string()
522        } else {
523            let owner_id = booking
524                .booked_by
525                .ok_or("Booking has neither an owner nor on_behalf_of_acp".to_string())?;
526            let owner = self
527                .owner_repo
528                .find_by_id(owner_id)
529                .await?
530                .ok_or("Booking owner not found".to_string())?;
531            format!("{} {}", owner.first_name, owner.last_name)
532        };
533
534        Ok(ResourceBookingResponseDto::from_entity(
535            booking,
536            booked_by_name,
537        ))
538    }
539
540    /// Helper: Enrich multiple bookings with owner names
541    async fn enrich_bookings_response(
542        &self,
543        bookings: Vec<ResourceBooking>,
544    ) -> Result<Vec<ResourceBookingResponseDto>, String> {
545        let mut result = Vec::with_capacity(bookings.len());
546
547        for booking in bookings {
548            let enriched = self.enrich_booking_response(booking).await?;
549            result.push(enriched);
550        }
551
552        Ok(result)
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::application::dto::{BookingStatisticsDto, OwnerFilters, PageRequest};
560    use crate::application::ports::{OwnerRepository, ResourceBookingRepository};
561    use crate::domain::entities::{
562        BookingStatus, Owner, RecurringPattern, ResourceBooking, ResourceType,
563    };
564    use async_trait::async_trait;
565    use chrono::{DateTime, Duration, Utc};
566    use std::collections::HashMap;
567    use std::sync::{Arc, Mutex};
568    use uuid::Uuid;
569
570    // ── Mock ResourceBookingRepository ──────────────────────────────────────
571    struct MockBookingRepo {
572        bookings: Mutex<HashMap<Uuid, ResourceBooking>>,
573    }
574
575    impl MockBookingRepo {
576        fn new() -> Self {
577            Self {
578                bookings: Mutex::new(HashMap::new()),
579            }
580        }
581    }
582
583    #[async_trait]
584    impl ResourceBookingRepository for MockBookingRepo {
585        async fn create(&self, booking: &ResourceBooking) -> Result<ResourceBooking, String> {
586            let mut map = self.bookings.lock().unwrap();
587            map.insert(booking.id, booking.clone());
588            Ok(booking.clone())
589        }
590
591        async fn find_by_id(&self, id: Uuid) -> Result<Option<ResourceBooking>, String> {
592            let map = self.bookings.lock().unwrap();
593            Ok(map.get(&id).cloned())
594        }
595
596        async fn find_by_building(
597            &self,
598            building_id: Uuid,
599        ) -> Result<Vec<ResourceBooking>, String> {
600            let map = self.bookings.lock().unwrap();
601            Ok(map
602                .values()
603                .filter(|b| b.building_id == building_id)
604                .cloned()
605                .collect())
606        }
607
608        async fn find_by_building_and_resource_type(
609            &self,
610            building_id: Uuid,
611            resource_type: ResourceType,
612        ) -> Result<Vec<ResourceBooking>, String> {
613            let map = self.bookings.lock().unwrap();
614            Ok(map
615                .values()
616                .filter(|b| b.building_id == building_id && b.resource_type == resource_type)
617                .cloned()
618                .collect())
619        }
620
621        async fn find_by_resource(
622            &self,
623            building_id: Uuid,
624            resource_type: ResourceType,
625            resource_name: &str,
626        ) -> Result<Vec<ResourceBooking>, String> {
627            let map = self.bookings.lock().unwrap();
628            Ok(map
629                .values()
630                .filter(|b| {
631                    b.building_id == building_id
632                        && b.resource_type == resource_type
633                        && b.resource_name == resource_name
634                })
635                .cloned()
636                .collect())
637        }
638
639        async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<ResourceBooking>, String> {
640            let map = self.bookings.lock().unwrap();
641            Ok(map
642                .values()
643                .filter(|b| b.booked_by == Some(user_id))
644                .cloned()
645                .collect())
646        }
647
648        async fn find_by_user_and_status(
649            &self,
650            user_id: Uuid,
651            status: BookingStatus,
652        ) -> Result<Vec<ResourceBooking>, String> {
653            let map = self.bookings.lock().unwrap();
654            Ok(map
655                .values()
656                .filter(|b| b.booked_by == Some(user_id) && b.status == status)
657                .cloned()
658                .collect())
659        }
660
661        async fn find_by_building_and_status(
662            &self,
663            building_id: Uuid,
664            status: BookingStatus,
665        ) -> Result<Vec<ResourceBooking>, String> {
666            let map = self.bookings.lock().unwrap();
667            Ok(map
668                .values()
669                .filter(|b| b.building_id == building_id && b.status == status)
670                .cloned()
671                .collect())
672        }
673
674        async fn find_upcoming(
675            &self,
676            building_id: Uuid,
677            _limit: Option<i64>,
678        ) -> Result<Vec<ResourceBooking>, String> {
679            let map = self.bookings.lock().unwrap();
680            let now = Utc::now();
681            Ok(map
682                .values()
683                .filter(|b| {
684                    b.building_id == building_id
685                        && b.start_time > now
686                        && matches!(b.status, BookingStatus::Pending | BookingStatus::Confirmed)
687                })
688                .cloned()
689                .collect())
690        }
691
692        async fn find_active(&self, building_id: Uuid) -> Result<Vec<ResourceBooking>, String> {
693            let map = self.bookings.lock().unwrap();
694            let now = Utc::now();
695            Ok(map
696                .values()
697                .filter(|b| {
698                    b.building_id == building_id
699                        && b.status == BookingStatus::Confirmed
700                        && now >= b.start_time
701                        && now < b.end_time
702                })
703                .cloned()
704                .collect())
705        }
706
707        async fn find_past(
708            &self,
709            building_id: Uuid,
710            _limit: Option<i64>,
711        ) -> Result<Vec<ResourceBooking>, String> {
712            let map = self.bookings.lock().unwrap();
713            let now = Utc::now();
714            Ok(map
715                .values()
716                .filter(|b| b.building_id == building_id && b.end_time < now)
717                .cloned()
718                .collect())
719        }
720
721        async fn find_conflicts(
722            &self,
723            building_id: Uuid,
724            resource_type: ResourceType,
725            resource_name: &str,
726            start_time: DateTime<Utc>,
727            end_time: DateTime<Utc>,
728            exclude_booking_id: Option<Uuid>,
729        ) -> Result<Vec<ResourceBooking>, String> {
730            let map = self.bookings.lock().unwrap();
731            Ok(map
732                .values()
733                .filter(|b| {
734                    b.building_id == building_id
735                        && b.resource_type == resource_type
736                        && b.resource_name == resource_name
737                        && matches!(b.status, BookingStatus::Pending | BookingStatus::Confirmed)
738                        && b.start_time < end_time
739                        && start_time < b.end_time
740                        && exclude_booking_id.is_none_or(|id| b.id != id)
741                })
742                .cloned()
743                .collect())
744        }
745
746        async fn update(&self, booking: &ResourceBooking) -> Result<ResourceBooking, String> {
747            let mut map = self.bookings.lock().unwrap();
748            map.insert(booking.id, booking.clone());
749            Ok(booking.clone())
750        }
751
752        async fn delete(&self, id: Uuid) -> Result<(), String> {
753            let mut map = self.bookings.lock().unwrap();
754            map.remove(&id);
755            Ok(())
756        }
757
758        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
759            let map = self.bookings.lock().unwrap();
760            Ok(map
761                .values()
762                .filter(|b| b.building_id == building_id)
763                .count() as i64)
764        }
765
766        async fn count_by_building_and_status(
767            &self,
768            building_id: Uuid,
769            status: BookingStatus,
770        ) -> Result<i64, String> {
771            let map = self.bookings.lock().unwrap();
772            Ok(map
773                .values()
774                .filter(|b| b.building_id == building_id && b.status == status)
775                .count() as i64)
776        }
777
778        async fn count_by_resource(
779            &self,
780            building_id: Uuid,
781            resource_type: ResourceType,
782            resource_name: &str,
783        ) -> Result<i64, String> {
784            let map = self.bookings.lock().unwrap();
785            Ok(map
786                .values()
787                .filter(|b| {
788                    b.building_id == building_id
789                        && b.resource_type == resource_type
790                        && b.resource_name == resource_name
791                })
792                .count() as i64)
793        }
794
795        async fn get_statistics(&self, building_id: Uuid) -> Result<BookingStatisticsDto, String> {
796            Ok(BookingStatisticsDto {
797                building_id,
798                total_bookings: 0,
799                confirmed_bookings: 0,
800                pending_bookings: 0,
801                cancelled_bookings: 0,
802                completed_bookings: 0,
803                no_show_bookings: 0,
804                active_bookings: 0,
805                upcoming_bookings: 0,
806                total_hours_booked: 0.0,
807                most_popular_resource: None,
808            })
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
824        fn add_owner(&self, owner: Owner) {
825            let mut map = self.owners.lock().unwrap();
826            map.insert(owner.id, owner);
827        }
828    }
829
830    #[async_trait]
831    impl OwnerRepository for MockOwnerRepo {
832        async fn create(&self, owner: &Owner) -> Result<Owner, String> {
833            let mut map = self.owners.lock().unwrap();
834            map.insert(owner.id, owner.clone());
835            Ok(owner.clone())
836        }
837
838        async fn find_by_id(&self, id: Uuid) -> Result<Option<Owner>, String> {
839            let map = self.owners.lock().unwrap();
840            Ok(map.get(&id).cloned())
841        }
842
843        async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<Owner>, String> {
844            let map = self.owners.lock().unwrap();
845            Ok(map.values().find(|o| o.user_id == Some(user_id)).cloned())
846        }
847
848        async fn find_by_user_id_and_organization(
849            &self,
850            user_id: Uuid,
851            organization_id: Uuid,
852        ) -> Result<Option<Owner>, String> {
853            let map = self.owners.lock().unwrap();
854            Ok(map
855                .values()
856                .find(|o| o.user_id == Some(user_id) && o.organization_id == organization_id)
857                .cloned())
858        }
859
860        async fn find_by_email(&self, email: &str) -> Result<Option<Owner>, String> {
861            let map = self.owners.lock().unwrap();
862            Ok(map.values().find(|o| o.email == email).cloned())
863        }
864
865        async fn find_all(&self) -> Result<Vec<Owner>, String> {
866            let map = self.owners.lock().unwrap();
867            Ok(map.values().cloned().collect())
868        }
869
870        async fn find_all_paginated(
871            &self,
872            _page_request: &PageRequest,
873            _filters: &OwnerFilters,
874        ) -> Result<(Vec<Owner>, i64), String> {
875            let map = self.owners.lock().unwrap();
876            let all: Vec<_> = map.values().cloned().collect();
877            let count = all.len() as i64;
878            Ok((all, count))
879        }
880
881        async fn update(&self, owner: &Owner) -> Result<Owner, String> {
882            let mut map = self.owners.lock().unwrap();
883            map.insert(owner.id, owner.clone());
884            Ok(owner.clone())
885        }
886
887        async fn delete(&self, id: Uuid) -> Result<bool, String> {
888            let mut map = self.owners.lock().unwrap();
889            Ok(map.remove(&id).is_some())
890        }
891
892        async fn set_user_link(
893            &self,
894            owner_id: Uuid,
895            user_id: Option<Uuid>,
896        ) -> Result<bool, String> {
897            let mut map = self.owners.lock().unwrap();
898            if let Some(o) = map.get_mut(&owner_id) {
899                o.user_id = user_id;
900                Ok(true)
901            } else {
902                Ok(false)
903            }
904        }
905    }
906
907    // ── Helpers ─────────────────────────────────────────────────────────────
908    fn create_test_owner(user_id: Uuid, organization_id: Uuid) -> Owner {
909        let mut owner = Owner::new(
910            organization_id,
911            "Jean".to_string(),
912            "Dupont".to_string(),
913            "jean@test.com".to_string(),
914            None,
915            "Rue Test 1".to_string(),
916            "Brussels".to_string(),
917            "1000".to_string(),
918            "Belgium".to_string(),
919        )
920        .unwrap();
921        owner.user_id = Some(user_id);
922        owner
923    }
924
925    fn setup_use_cases() -> (
926        ResourceBookingUseCases,
927        Uuid,
928        Uuid,
929        Uuid,
930        Arc<MockBookingRepo>,
931    ) {
932        let user_id = Uuid::new_v4();
933        let organization_id = Uuid::new_v4();
934        let building_id = Uuid::new_v4();
935
936        let booking_repo = Arc::new(MockBookingRepo::new());
937        let owner_repo = Arc::new(MockOwnerRepo::new());
938
939        let owner = create_test_owner(user_id, organization_id);
940        owner_repo.add_owner(owner);
941
942        let use_cases = ResourceBookingUseCases::new(
943            booking_repo.clone() as Arc<dyn ResourceBookingRepository>,
944            owner_repo as Arc<dyn OwnerRepository>,
945        );
946
947        (
948            use_cases,
949            user_id,
950            organization_id,
951            building_id,
952            booking_repo,
953        )
954    }
955
956    fn make_create_dto(building_id: Uuid) -> CreateResourceBookingDto {
957        let start_time = Utc::now() + Duration::hours(2);
958        let end_time = start_time + Duration::hours(2);
959        CreateResourceBookingDto {
960            building_id,
961            resource_type: ResourceType::MeetingRoom,
962            resource_name: "Meeting Room A".to_string(),
963            start_time,
964            end_time,
965            notes: Some("Team standup".to_string()),
966            recurring_pattern: RecurringPattern::None,
967            recurrence_end_date: None,
968            max_duration_hours: None,
969            max_advance_days: None,
970            on_behalf_of_acp: false,
971            motif: None,
972        }
973    }
974
975    // ── Tests ───────────────────────────────────────────────────────────────
976
977    #[tokio::test]
978    async fn test_create_booking_success() {
979        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
980        let dto = make_create_dto(building_id);
981
982        let result = use_cases.create_booking(user_id, org_id, false, dto).await;
983        assert!(result.is_ok());
984        let response = result.unwrap();
985        assert_eq!(response.building_id, building_id);
986        assert_eq!(response.resource_name, "Meeting Room A");
987        assert_eq!(response.booked_by_name, "Jean Dupont");
988    }
989
990    #[tokio::test]
991    async fn test_create_booking_conflict_detected() {
992        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
993        let dto = make_create_dto(building_id);
994
995        // First booking succeeds
996        use_cases
997            .create_booking(user_id, org_id, false, dto.clone())
998            .await
999            .unwrap();
1000
1001        // Second booking for same resource and time range should fail
1002        let result = use_cases.create_booking(user_id, org_id, false, dto).await;
1003        assert!(result.is_err());
1004        assert!(result.unwrap_err().contains("conflicts with"));
1005    }
1006
1007    #[tokio::test]
1008    async fn test_get_booking_success() {
1009        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1010        let dto = make_create_dto(building_id);
1011
1012        let created = use_cases
1013            .create_booking(user_id, org_id, false, dto)
1014            .await
1015            .unwrap();
1016
1017        let result = use_cases.get_booking(created.id).await;
1018        assert!(result.is_ok());
1019        assert_eq!(result.unwrap().id, created.id);
1020    }
1021
1022    #[tokio::test]
1023    async fn test_get_booking_not_found() {
1024        let (use_cases, _, _, _, _) = setup_use_cases();
1025        let result = use_cases.get_booking(Uuid::new_v4()).await;
1026        assert!(result.is_err());
1027        assert_eq!(result.unwrap_err(), "Booking not found");
1028    }
1029
1030    #[tokio::test]
1031    async fn test_cancel_booking_success() {
1032        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1033        let dto = make_create_dto(building_id);
1034
1035        let created = use_cases
1036            .create_booking(user_id, org_id, false, dto)
1037            .await
1038            .unwrap();
1039
1040        let result = use_cases.cancel_booking(created.id, user_id, org_id).await;
1041        assert!(result.is_ok());
1042        let cancelled = result.unwrap();
1043        assert_eq!(cancelled.status, BookingStatus::Cancelled);
1044    }
1045
1046    #[tokio::test]
1047    async fn test_cancel_booking_wrong_user() {
1048        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1049        let dto = make_create_dto(building_id);
1050
1051        let created = use_cases
1052            .create_booking(user_id, org_id, false, dto)
1053            .await
1054            .unwrap();
1055
1056        // Try cancelling with a different user who is not registered as owner
1057        let other_user = Uuid::new_v4();
1058        let result = use_cases
1059            .cancel_booking(created.id, other_user, org_id)
1060            .await;
1061        assert!(result.is_err());
1062        assert_eq!(
1063            result.unwrap_err(),
1064            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES,
1065            "le refus doit être celui, nommé, opposé à qui n'est pas copropriétaire"
1066        );
1067    }
1068
1069    #[tokio::test]
1070    async fn test_delete_booking_success() {
1071        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1072        let dto = make_create_dto(building_id);
1073
1074        let created = use_cases
1075            .create_booking(user_id, org_id, false, dto)
1076            .await
1077            .unwrap();
1078
1079        let result = use_cases.delete_booking(created.id, user_id, org_id).await;
1080        assert!(result.is_ok());
1081
1082        // Confirm it's gone
1083        let fetch = use_cases.get_booking(created.id).await;
1084        assert!(fetch.is_err());
1085    }
1086
1087    #[tokio::test]
1088    async fn test_confirm_booking_success() {
1089        let (use_cases, user_id, org_id, building_id, _booking_repo) = setup_use_cases();
1090        let dto = make_create_dto(building_id);
1091
1092        let created = use_cases
1093            .create_booking(user_id, org_id, false, dto)
1094            .await
1095            .unwrap();
1096
1097        // Manually confirm via the use case
1098        let result = use_cases.confirm_booking(created.id).await;
1099        assert!(result.is_ok());
1100        let confirmed = result.unwrap();
1101        assert_eq!(confirmed.status, BookingStatus::Confirmed);
1102
1103        // Now we can complete it
1104        let completed = use_cases.complete_booking(created.id).await;
1105        assert!(completed.is_ok());
1106        assert_eq!(completed.unwrap().status, BookingStatus::Completed);
1107    }
1108
1109    #[tokio::test]
1110    async fn test_list_building_bookings() {
1111        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1112
1113        let dto1 = make_create_dto(building_id);
1114
1115        let mut dto2 = make_create_dto(building_id);
1116        dto2.resource_name = "Meeting Room B".to_string();
1117
1118        use_cases
1119            .create_booking(user_id, org_id, false, dto1)
1120            .await
1121            .unwrap();
1122        use_cases
1123            .create_booking(user_id, org_id, false, dto2)
1124            .await
1125            .unwrap();
1126
1127        let result = use_cases.list_building_bookings(building_id).await;
1128        assert!(result.is_ok());
1129        assert_eq!(result.unwrap().len(), 2);
1130    }
1131
1132    #[tokio::test]
1133    async fn test_owner_not_found_for_user() {
1134        let booking_repo = Arc::new(MockBookingRepo::new());
1135        let owner_repo = Arc::new(MockOwnerRepo::new());
1136        // Do not add any owner
1137        let use_cases = ResourceBookingUseCases::new(
1138            booking_repo as Arc<dyn ResourceBookingRepository>,
1139            owner_repo as Arc<dyn OwnerRepository>,
1140        );
1141
1142        let building_id = Uuid::new_v4();
1143        let dto = make_create_dto(building_id);
1144        let result = use_cases
1145            .create_booking(Uuid::new_v4(), Uuid::new_v4(), false, dto)
1146            .await;
1147        assert!(result.is_err());
1148        assert_eq!(
1149            result.unwrap_err(),
1150            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES,
1151            "le refus doit être celui, nommé, opposé à qui n'est pas copropriétaire"
1152        );
1153    }
1154
1155    // ------------------------------------------------------------------------
1156    // Story #588 (INV-5/FR27) — on_behalf_of_acp, taxonomie 4-cat
1157    // ------------------------------------------------------------------------
1158
1159    fn make_on_behalf_dto(building_id: Uuid, motif: Option<&str>) -> CreateResourceBookingDto {
1160        let mut dto = make_create_dto(building_id);
1161        dto.on_behalf_of_acp = true;
1162        dto.motif = motif.map(|m| m.to_string());
1163        dto
1164    }
1165
1166    #[tokio::test]
1167    async fn happy_syndic_books_on_behalf_of_acp_with_motif() {
1168        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1169        let dto = make_on_behalf_dto(building_id, Some("AG annuelle"));
1170
1171        // La légitimité vient de `is_syndic = true` : ce user_id a bien une
1172        // fiche Owner (créée par `setup_use_cases()`), mais le chemin
1173        // on_behalf_of_acp ne la consulte jamais — la garde est le rôle, pas
1174        // l'existence d'une fiche copropriétaire.
1175        let result = use_cases.create_booking(user_id, org_id, true, dto).await;
1176        assert!(result.is_ok(), "{:?}", result.err());
1177        let response = result.unwrap();
1178        assert!(response.on_behalf_of_acp);
1179        assert_eq!(response.motif.as_deref(), Some("AG annuelle"));
1180        assert_eq!(response.booked_by, None);
1181        assert_eq!(response.booked_by_name, "Syndic — pour le compte de l'ACP");
1182    }
1183
1184    #[tokio::test]
1185    async fn negative_on_behalf_of_acp_without_motif_is_refused() {
1186        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1187        let dto = make_on_behalf_dto(building_id, None);
1188
1189        let result = use_cases.create_booking(user_id, org_id, true, dto).await;
1190        assert_eq!(
1191            result.unwrap_err(),
1192            crate::domain::entities::ReservationOnBehalfError::MotifRequired.to_string()
1193        );
1194    }
1195
1196    #[tokio::test]
1197    async fn security_non_syndic_cannot_set_on_behalf_of_acp() {
1198        let (use_cases, user_id, org_id, building_id, _) = setup_use_cases();
1199        let dto = make_on_behalf_dto(building_id, Some("AG annuelle"));
1200
1201        // is_syndic = false : ce user_id a pourtant une fiche Owner (créée
1202        // par `setup_use_cases()`) — la garde ne doit pas dépendre de
1203        // l'existence d'une fiche copropriétaire, mais du rôle applicatif.
1204        let result = use_cases.create_booking(user_id, org_id, false, dto).await;
1205        assert_eq!(
1206            result.unwrap_err(),
1207            crate::application::error::REFUS_ON_BEHALF_RESERVE_AUX_SYNDICS,
1208        );
1209    }
1210
1211    #[tokio::test]
1212    async fn edge_syndic_without_owner_profile_and_on_behalf_of_acp_false_is_refused() {
1213        // AC @edge — "Syndic réserve on_behalf_of_acp=false → 403" : sans
1214        // fiche Owner, le chemin personnel reste refusé (INV-5). Ce n'est pas
1215        // une régression à corriger : c'est précisément l'absence de
1216        // participation personnelle que l'exception #588 vient nommer.
1217        let booking_repo = Arc::new(MockBookingRepo::new());
1218        let owner_repo = Arc::new(MockOwnerRepo::new()); // aucun owner enregistré
1219        let use_cases = ResourceBookingUseCases::new(
1220            booking_repo as Arc<dyn ResourceBookingRepository>,
1221            owner_repo as Arc<dyn OwnerRepository>,
1222        );
1223        let building_id = Uuid::new_v4();
1224        let dto = make_create_dto(building_id); // on_behalf_of_acp: false
1225
1226        let result = use_cases
1227            .create_booking(Uuid::new_v4(), Uuid::new_v4(), true, dto)
1228            .await;
1229        assert_eq!(
1230            result.unwrap_err(),
1231            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES,
1232        );
1233    }
1234}