Skip to main content

koprogo_api/domain/economie_circulaire/
resource_booking.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Resource types available for booking in a building
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
7pub enum ResourceType {
8    MeetingRoom,
9    LaundryRoom,
10    Gym,
11    Rooftop,
12    ParkingSpot,
13    CommonSpace,
14    GuestRoom,
15    BikeStorage,
16    Other,
17}
18
19/// Booking status lifecycle
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
21pub enum BookingStatus {
22    Pending,   // Awaiting confirmation (if approval required)
23    Confirmed, // Booking confirmed
24    Cancelled, // Cancelled by user or admin
25    Completed, // Booking completed (auto-set after end_time)
26    NoShow,    // User didn't show up (admin-set)
27}
28
29/// Recurring pattern for repeated bookings
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
31pub enum RecurringPattern {
32    #[default]
33    None,
34    Daily,
35    Weekly,
36    Monthly,
37}
38
39/// Resource booking entity for community space reservations
40///
41/// Represents a booking for shared building resources (meeting rooms, laundry, gym, etc.)
42/// with conflict detection, duration limits, and recurring booking support.
43///
44/// # Belgian Legal Context
45/// - Common spaces in Belgian copropriétés are shared property (Article 3 Loi Copropriété)
46/// - Syndic can regulate usage to ensure fair access for all co-owners
47/// - Booking system provides transparent allocation and prevents conflicts
48///
49/// # Business Rules
50/// - start_time must be < end_time
51/// - start_time must be in the future (no past bookings)
52/// - Duration must not exceed max_duration_hours (configurable per resource)
53/// - No overlapping bookings for the same resource
54/// - Advance booking limit (e.g., max 30 days ahead)
55/// - Only booking owner can cancel their own bookings
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ResourceBooking {
58    pub id: Uuid,
59    pub building_id: Uuid,
60    pub resource_type: ResourceType,
61    pub resource_name: String, // e.g., "Meeting Room A", "Laundry Room 1st Floor"
62    // Story #588 (INV-5/FR27) — un syndic n'a structurellement pas de fiche
63    // de copropriétaire (cf. `resolve_owner()` dans
64    // `resource_booking_use_cases.rs`). Une réservation "pour le compte de
65    // l'ACP" ne peut donc pas prétendre à un `owner_id` : elle porte
66    // `booked_by_user_id` à la place, et `booked_by` reste `None`. Les deux
67    // champs sont mutuellement exclusifs (cf. `on_behalf_of_acp`).
68    pub booked_by: Option<Uuid>, // owner_id who made the booking (None si on_behalf_of_acp)
69    pub booked_by_user_id: Option<Uuid>, // syndic user_id si on_behalf_of_acp
70    pub on_behalf_of_acp: bool,
71    pub motif: Option<String>, // obligatoire si on_behalf_of_acp
72    pub start_time: DateTime<Utc>,
73    pub end_time: DateTime<Utc>,
74    pub status: BookingStatus,
75    pub notes: Option<String>,
76    pub recurring_pattern: RecurringPattern,
77    pub recurrence_end_date: Option<DateTime<Utc>>, // For recurring bookings
78    pub created_at: DateTime<Utc>,
79    pub updated_at: DateTime<Utc>,
80}
81
82/// Story #588 (INV-5/FR27) — erreur typée pour l'exception syndic
83/// "réservation pour le compte de l'ACP". Suit le pattern déjà établi par
84/// `ChargeDistributionError` (entité → erreur typée → bridge `String` pour
85/// les use-cases legacy, bridge `AppError` pour la 422 côté HTTP).
86#[derive(Debug, Clone, PartialEq)]
87pub enum ReservationOnBehalfError {
88    /// `on_behalf_of_acp = true` sans motif (ou motif vide/blanc) : l'exception
89    /// à l'interdiction de participation personnelle du syndic (INV-5) ne
90    /// serait pas traçable sans lui.
91    MotifRequired,
92}
93
94impl std::fmt::Display for ReservationOnBehalfError {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::MotifRequired => write!(
98                f,
99                "Une réservation pour le compte de l'ACP doit porter un motif (AG, prestataire…)"
100            ),
101        }
102    }
103}
104
105impl std::error::Error for ReservationOnBehalfError {}
106
107/// Bridge pour les use-cases `Result<_, String>` existants (mêmes raisons que
108/// `ChargeDistributionError` : pas de refacto en cascade du module hors
109/// scope de la story #588).
110impl From<ReservationOnBehalfError> for String {
111    fn from(e: ReservationOnBehalfError) -> String {
112        e.to_string()
113    }
114}
115
116impl ResourceBooking {
117    /// Maximum duration in hours per booking (default: 4 hours)
118    pub const DEFAULT_MAX_DURATION_HOURS: i64 = 4;
119
120    /// Maximum advance booking in days (default: 30 days)
121    pub const DEFAULT_MAX_ADVANCE_DAYS: i64 = 30;
122
123    /// Minimum booking duration in minutes (default: 30 minutes)
124    pub const MIN_DURATION_MINUTES: i64 = 30;
125
126    /// Create a new resource booking
127    ///
128    /// # Validation
129    /// - resource_name must be 3-100 characters
130    /// - start_time must be < end_time
131    /// - start_time must be in the future
132    /// - Duration must be >= MIN_DURATION_MINUTES
133    /// - Duration must be <= max_duration_hours
134    /// - start_time must be <= max_advance_days in the future
135    /// - For recurring bookings, recurrence_end_date must be provided
136    ///
137    /// # Arguments
138    /// - `building_id` - Building where resource is located
139    /// - `resource_type` - Type of resource being booked
140    /// - `resource_name` - Specific resource name (e.g., "Meeting Room A")
141    /// - `booked_by` - Owner ID making the booking
142    /// - `start_time` - Booking start time
143    /// - `end_time` - Booking end time
144    /// - `notes` - Optional notes for the booking
145    /// - `recurring_pattern` - Recurring pattern (None, Daily, Weekly, Monthly)
146    /// - `recurrence_end_date` - End date for recurring bookings
147    /// - `max_duration_hours` - Max duration allowed (defaults to 4 hours)
148    /// - `max_advance_days` - Max advance booking allowed (defaults to 30 days)
149    pub fn new(
150        building_id: Uuid,
151        resource_type: ResourceType,
152        resource_name: String,
153        booked_by: Uuid,
154        start_time: DateTime<Utc>,
155        end_time: DateTime<Utc>,
156        notes: Option<String>,
157        recurring_pattern: RecurringPattern,
158        recurrence_end_date: Option<DateTime<Utc>>,
159        max_duration_hours: Option<i64>,
160        max_advance_days: Option<i64>,
161    ) -> Result<Self, String> {
162        // Validate resource_name length
163        if resource_name.len() < 3 || resource_name.len() > 100 {
164            return Err("Resource name must be 3-100 characters".to_string());
165        }
166
167        // Validate start_time < end_time
168        if start_time >= end_time {
169            return Err("Start time must be before end time".to_string());
170        }
171
172        // Validate start_time is in the future
173        let now = Utc::now();
174        if start_time <= now {
175            return Err("Cannot book resources in the past".to_string());
176        }
177
178        // Validate minimum duration
179        let duration = end_time.signed_duration_since(start_time);
180        if duration.num_minutes() < Self::MIN_DURATION_MINUTES {
181            return Err(format!(
182                "Booking duration must be at least {} minutes",
183                Self::MIN_DURATION_MINUTES
184            ));
185        }
186
187        // Validate maximum duration
188        let max_hours = max_duration_hours.unwrap_or(Self::DEFAULT_MAX_DURATION_HOURS);
189        if duration.num_hours() > max_hours {
190            return Err(format!(
191                "Booking duration cannot exceed {} hours",
192                max_hours
193            ));
194        }
195
196        // Validate advance booking limit
197        let max_advance = max_advance_days.unwrap_or(Self::DEFAULT_MAX_ADVANCE_DAYS);
198        let advance_duration = start_time.signed_duration_since(now);
199        if advance_duration.num_days() > max_advance {
200            return Err(format!(
201                "Cannot book more than {} days in advance",
202                max_advance
203            ));
204        }
205
206        // Validate recurring pattern
207        if recurring_pattern != RecurringPattern::None && recurrence_end_date.is_none() {
208            return Err("Recurring bookings must have a recurrence end date".to_string());
209        }
210
211        if let Some(recurrence_end) = recurrence_end_date {
212            if recurrence_end <= start_time {
213                return Err("Recurrence end date must be after start time".to_string());
214            }
215        }
216
217        // Validate notes length
218        if let Some(ref n) = notes {
219            if n.len() > 500 {
220                return Err("Notes cannot exceed 500 characters".to_string());
221            }
222        }
223
224        let now = Utc::now();
225        Ok(Self {
226            id: Uuid::new_v4(),
227            building_id,
228            resource_type,
229            resource_name,
230            booked_by: Some(booked_by),
231            booked_by_user_id: None,
232            on_behalf_of_acp: false,
233            motif: None,
234            start_time,
235            end_time,
236            status: BookingStatus::Pending, // Pending until syndic confirms
237            notes,
238            recurring_pattern,
239            recurrence_end_date,
240            created_at: now,
241            updated_at: now,
242        })
243    }
244
245    /// Create a booking made by a syndic on behalf of the ACP (AG,
246    /// prestataires) rather than a co-owner personally.
247    ///
248    /// Story #588 (INV-5/FR27) — l'exception à l'interdiction de
249    /// participation personnelle du syndic n'existe que motivée : `motif`
250    /// est obligatoire (vide ou blanc = refusé). La légitimité du DEMANDEUR
251    /// (est-il bien syndic ?) n'est PAS du ressort du domaine — elle est
252    /// tranchée en amont, côté use-case/RBAC (cf. `ResourceBookingUseCases::
253    /// create_booking`), car elle dépend d'un rôle applicatif, pas d'un
254    /// invariant de l'entité.
255    ///
256    /// Délègue à `new()` pour les invariants partagés (durée, avance,
257    /// récurrence) plutôt que de les dupliquer : seule la question « qui a
258    /// réservé » change entre les deux chemins. `syndic_user_id` est passé à
259    /// `new()` comme `booked_by` temporaire, uniquement pour réutiliser sa
260    /// validation ; il est aussitôt déplacé vers `booked_by_user_id` et
261    /// `booked_by` est remis à `None` avant de rendre la main.
262    #[allow(clippy::too_many_arguments)]
263    pub fn new_on_behalf_of_acp(
264        building_id: Uuid,
265        resource_type: ResourceType,
266        resource_name: String,
267        syndic_user_id: Uuid,
268        motif: String,
269        start_time: DateTime<Utc>,
270        end_time: DateTime<Utc>,
271        notes: Option<String>,
272        recurring_pattern: RecurringPattern,
273        recurrence_end_date: Option<DateTime<Utc>>,
274        max_duration_hours: Option<i64>,
275        max_advance_days: Option<i64>,
276    ) -> Result<Self, String> {
277        let trimmed_motif = motif.trim();
278        if trimmed_motif.is_empty() {
279            return Err(ReservationOnBehalfError::MotifRequired.into());
280        }
281
282        let mut booking = Self::new(
283            building_id,
284            resource_type,
285            resource_name,
286            syndic_user_id,
287            start_time,
288            end_time,
289            notes,
290            recurring_pattern,
291            recurrence_end_date,
292            max_duration_hours,
293            max_advance_days,
294        )?;
295
296        booking.booked_by = None;
297        booking.booked_by_user_id = Some(syndic_user_id);
298        booking.on_behalf_of_acp = true;
299        booking.motif = Some(trimmed_motif.to_string());
300
301        Ok(booking)
302    }
303
304    /// Cancel this booking
305    ///
306    /// Only allowed for Pending or Confirmed bookings.
307    /// Cannot cancel Completed, Cancelled, or NoShow bookings.
308    ///
309    /// # Arguments
310    /// - `canceller_id` - User ID requesting cancellation
311    ///
312    /// # Returns
313    /// - Ok(()) if cancellation successful
314    /// - Err if booking cannot be cancelled
315    pub fn cancel(&mut self, canceller_id: Uuid) -> Result<(), String> {
316        // Only booking owner can cancel
317        if self.booked_by != Some(canceller_id) {
318            return Err("Only the booking owner can cancel this booking".to_string());
319        }
320
321        // Can only cancel Pending or Confirmed bookings
322        match self.status {
323            BookingStatus::Pending | BookingStatus::Confirmed => {
324                self.status = BookingStatus::Cancelled;
325                self.updated_at = Utc::now();
326                Ok(())
327            }
328            BookingStatus::Cancelled => Err("Booking is already cancelled".to_string()),
329            BookingStatus::Completed => Err("Cannot cancel a completed booking".to_string()),
330            BookingStatus::NoShow => Err("Cannot cancel a no-show booking".to_string()),
331        }
332    }
333
334    /// Mark booking as completed
335    ///
336    /// Typically called automatically after end_time passes.
337    /// Only Confirmed bookings can be marked as completed.
338    pub fn complete(&mut self) -> Result<(), String> {
339        match self.status {
340            BookingStatus::Confirmed => {
341                self.status = BookingStatus::Completed;
342                self.updated_at = Utc::now();
343                Ok(())
344            }
345            BookingStatus::Pending => {
346                Err("Cannot complete a pending booking (confirm first)".to_string())
347            }
348            BookingStatus::Cancelled => Err("Cannot complete a cancelled booking".to_string()),
349            BookingStatus::Completed => Err("Booking is already completed".to_string()),
350            BookingStatus::NoShow => Err("Cannot complete a no-show booking".to_string()),
351        }
352    }
353
354    /// Mark booking as no-show
355    ///
356    /// Called when user doesn't show up for their booking.
357    /// Only Confirmed bookings can be marked as no-show.
358    pub fn mark_no_show(&mut self) -> Result<(), String> {
359        match self.status {
360            BookingStatus::Confirmed => {
361                self.status = BookingStatus::NoShow;
362                self.updated_at = Utc::now();
363                Ok(())
364            }
365            BookingStatus::Pending => Err("Cannot mark pending booking as no-show".to_string()),
366            BookingStatus::Cancelled => Err("Cannot mark cancelled booking as no-show".to_string()),
367            BookingStatus::Completed => Err("Cannot mark completed booking as no-show".to_string()),
368            BookingStatus::NoShow => Err("Booking is already marked as no-show".to_string()),
369        }
370    }
371
372    /// Confirm a pending booking
373    ///
374    /// Only Pending bookings can be confirmed.
375    pub fn confirm(&mut self) -> Result<(), String> {
376        match self.status {
377            BookingStatus::Pending => {
378                self.status = BookingStatus::Confirmed;
379                self.updated_at = Utc::now();
380                Ok(())
381            }
382            BookingStatus::Confirmed => Err("Booking is already confirmed".to_string()),
383            BookingStatus::Cancelled => Err("Cannot confirm a cancelled booking".to_string()),
384            BookingStatus::Completed => Err("Cannot confirm a completed booking".to_string()),
385            BookingStatus::NoShow => Err("Cannot confirm a no-show booking".to_string()),
386        }
387    }
388
389    /// Update booking details (resource_name, notes)
390    ///
391    /// Only allowed for Pending or Confirmed bookings.
392    /// Time changes require cancellation and rebooking to ensure conflict detection.
393    pub fn update_details(
394        &mut self,
395        resource_name: Option<String>,
396        notes: Option<String>,
397    ) -> Result<(), String> {
398        // Can only update Pending or Confirmed bookings
399        if !matches!(
400            self.status,
401            BookingStatus::Pending | BookingStatus::Confirmed
402        ) {
403            return Err(format!(
404                "Cannot update booking with status: {:?}",
405                self.status
406            ));
407        }
408
409        // Update resource_name if provided
410        if let Some(name) = resource_name {
411            if name.len() < 3 || name.len() > 100 {
412                return Err("Resource name must be 3-100 characters".to_string());
413            }
414            self.resource_name = name;
415        }
416
417        // Update notes if provided
418        if let Some(n) = notes {
419            if n.len() > 500 {
420                return Err("Notes cannot exceed 500 characters".to_string());
421            }
422            self.notes = Some(n);
423        }
424
425        self.updated_at = Utc::now();
426        Ok(())
427    }
428
429    /// Check if booking is currently active (now is between start_time and end_time)
430    pub fn is_active(&self) -> bool {
431        let now = Utc::now();
432        self.status == BookingStatus::Confirmed && now >= self.start_time && now < self.end_time
433    }
434
435    /// Check if booking is in the past (end_time has passed)
436    pub fn is_past(&self) -> bool {
437        Utc::now() >= self.end_time
438    }
439
440    /// Check if booking is in the future (start_time hasn't arrived yet)
441    pub fn is_future(&self) -> bool {
442        Utc::now() < self.start_time
443    }
444
445    /// Calculate booking duration in hours
446    pub fn duration_hours(&self) -> f64 {
447        let duration = self.end_time.signed_duration_since(self.start_time);
448        duration.num_minutes() as f64 / 60.0
449    }
450
451    /// Check if this booking conflicts with another booking
452    ///
453    /// Conflict occurs if:
454    /// - Same building_id, resource_type, resource_name
455    /// - Time ranges overlap
456    /// - Other booking is Pending or Confirmed (not Cancelled/Completed/NoShow)
457    ///
458    /// Time overlap logic:
459    /// - Bookings overlap if: start1 < end2 AND start2 < end1
460    pub fn conflicts_with(&self, other: &ResourceBooking) -> bool {
461        // Must be same resource
462        if self.building_id != other.building_id
463            || self.resource_type != other.resource_type
464            || self.resource_name != other.resource_name
465        {
466            return false;
467        }
468
469        // Only check conflicts with active bookings (Pending or Confirmed)
470        if !matches!(
471            other.status,
472            BookingStatus::Pending | BookingStatus::Confirmed
473        ) {
474            return false;
475        }
476
477        // Check time overlap: start1 < end2 AND start2 < end1
478        self.start_time < other.end_time && other.start_time < self.end_time
479    }
480
481    /// Check if booking is modifiable (Pending or Confirmed)
482    pub fn is_modifiable(&self) -> bool {
483        matches!(
484            self.status,
485            BookingStatus::Pending | BookingStatus::Confirmed
486        )
487    }
488
489    /// Check if booking is recurring
490    pub fn is_recurring(&self) -> bool {
491        self.recurring_pattern != RecurringPattern::None
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn create_test_booking() -> ResourceBooking {
500        let building_id = Uuid::new_v4();
501        let booked_by = Uuid::new_v4();
502        let start_time = Utc::now() + chrono::Duration::hours(2);
503        let end_time = start_time + chrono::Duration::hours(2);
504
505        ResourceBooking::new(
506            building_id,
507            ResourceType::MeetingRoom,
508            "Meeting Room A".to_string(),
509            booked_by,
510            start_time,
511            end_time,
512            Some("Team meeting".to_string()),
513            RecurringPattern::None,
514            None,
515            None,
516            None,
517        )
518        .unwrap()
519    }
520
521    #[test]
522    fn test_create_booking_success() {
523        let booking = create_test_booking();
524        assert_eq!(booking.status, BookingStatus::Pending);
525        assert_eq!(booking.resource_type, ResourceType::MeetingRoom);
526        assert_eq!(booking.resource_name, "Meeting Room A");
527    }
528
529    #[test]
530    fn test_create_booking_invalid_resource_name() {
531        let building_id = Uuid::new_v4();
532        let booked_by = Uuid::new_v4();
533        let start_time = Utc::now() + chrono::Duration::hours(2);
534        let end_time = start_time + chrono::Duration::hours(2);
535
536        let result = ResourceBooking::new(
537            building_id,
538            ResourceType::MeetingRoom,
539            "AB".to_string(), // Too short
540            booked_by,
541            start_time,
542            end_time,
543            None,
544            RecurringPattern::None,
545            None,
546            None,
547            None,
548        );
549
550        assert!(result.is_err());
551        assert!(result
552            .unwrap_err()
553            .contains("Resource name must be 3-100 characters"));
554    }
555
556    #[test]
557    fn test_create_booking_start_after_end() {
558        let building_id = Uuid::new_v4();
559        let booked_by = Uuid::new_v4();
560        let start_time = Utc::now() + chrono::Duration::hours(4);
561        let end_time = start_time - chrono::Duration::hours(2); // End before start
562
563        let result = ResourceBooking::new(
564            building_id,
565            ResourceType::MeetingRoom,
566            "Meeting Room A".to_string(),
567            booked_by,
568            start_time,
569            end_time,
570            None,
571            RecurringPattern::None,
572            None,
573            None,
574            None,
575        );
576
577        assert!(result.is_err());
578        assert!(result
579            .unwrap_err()
580            .contains("Start time must be before end time"));
581    }
582
583    #[test]
584    fn test_create_booking_past_start_time() {
585        let building_id = Uuid::new_v4();
586        let booked_by = Uuid::new_v4();
587        let start_time = Utc::now() - chrono::Duration::hours(2); // Past
588        let end_time = start_time + chrono::Duration::hours(2);
589
590        let result = ResourceBooking::new(
591            building_id,
592            ResourceType::MeetingRoom,
593            "Meeting Room A".to_string(),
594            booked_by,
595            start_time,
596            end_time,
597            None,
598            RecurringPattern::None,
599            None,
600            None,
601            None,
602        );
603
604        assert!(result.is_err());
605        assert!(result
606            .unwrap_err()
607            .contains("Cannot book resources in the past"));
608    }
609
610    #[test]
611    fn test_create_booking_exceeds_max_duration() {
612        let building_id = Uuid::new_v4();
613        let booked_by = Uuid::new_v4();
614        let start_time = Utc::now() + chrono::Duration::hours(2);
615        let end_time = start_time + chrono::Duration::hours(6); // 6 hours (exceeds default 4h)
616
617        let result = ResourceBooking::new(
618            building_id,
619            ResourceType::MeetingRoom,
620            "Meeting Room A".to_string(),
621            booked_by,
622            start_time,
623            end_time,
624            None,
625            RecurringPattern::None,
626            None,
627            None,
628            None,
629        );
630
631        assert!(result.is_err());
632        assert!(result
633            .unwrap_err()
634            .contains("Booking duration cannot exceed"));
635    }
636
637    #[test]
638    fn test_create_booking_below_min_duration() {
639        let building_id = Uuid::new_v4();
640        let booked_by = Uuid::new_v4();
641        let start_time = Utc::now() + chrono::Duration::hours(2);
642        let end_time = start_time + chrono::Duration::minutes(15); // 15 minutes (below 30min min)
643
644        let result = ResourceBooking::new(
645            building_id,
646            ResourceType::MeetingRoom,
647            "Meeting Room A".to_string(),
648            booked_by,
649            start_time,
650            end_time,
651            None,
652            RecurringPattern::None,
653            None,
654            None,
655            None,
656        );
657
658        assert!(result.is_err());
659        assert!(result
660            .unwrap_err()
661            .contains("Booking duration must be at least"));
662    }
663
664    // `booked_by` est devenu `Option<Uuid>` — `None` quand la réservation est
665    // prise PAR le syndic POUR l'ACP (#588). Ces trois tests le passaient
666    // encore directement à `cancel`, qui attend l'identité de l'annulant.
667    //
668    // `expect` plutôt qu'`unwrap` : si la fixture cessait de poser un
669    // réservataire, le message dirait laquelle des deux choses a changé.
670    #[test]
671    fn happy_cancel_booking_success() {
672        let mut booking = create_test_booking();
673        let reservataire = booking.booked_by.expect("la fixture pose un réservataire");
674        let result = booking.cancel(reservataire);
675        assert!(result.is_ok());
676        assert_eq!(booking.status, BookingStatus::Cancelled);
677    }
678
679    #[test]
680    fn security_cancel_booking_wrong_user() {
681        let mut booking = create_test_booking();
682        let wrong_user = Uuid::new_v4();
683        let result = booking.cancel(wrong_user);
684        assert!(result.is_err());
685        assert!(result
686            .unwrap_err()
687            .contains("Only the booking owner can cancel"));
688    }
689
690    #[test]
691    fn negative_cancel_already_cancelled() {
692        let mut booking = create_test_booking();
693        let reservataire = booking.booked_by.expect("la fixture pose un réservataire");
694        booking.cancel(reservataire).expect("première annulation");
695        let result = booking.cancel(reservataire);
696        assert!(result.is_err());
697        assert!(result.unwrap_err().contains("already cancelled"));
698    }
699
700    #[test]
701    fn test_complete_booking_success() {
702        let mut booking = create_test_booking();
703        booking.confirm().unwrap();
704        let result = booking.complete();
705        assert!(result.is_ok());
706        assert_eq!(booking.status, BookingStatus::Completed);
707    }
708
709    #[test]
710    fn test_mark_no_show_success() {
711        let mut booking = create_test_booking();
712        booking.confirm().unwrap();
713        let result = booking.mark_no_show();
714        assert!(result.is_ok());
715        assert_eq!(booking.status, BookingStatus::NoShow);
716    }
717
718    #[test]
719    fn test_update_details_success() {
720        let mut booking = create_test_booking();
721        let result = booking.update_details(
722            Some("Meeting Room B".to_string()),
723            Some("Updated notes".to_string()),
724        );
725        assert!(result.is_ok());
726        assert_eq!(booking.resource_name, "Meeting Room B");
727        assert_eq!(booking.notes.unwrap(), "Updated notes");
728    }
729
730    #[test]
731    fn test_is_active() {
732        let booking_id = Uuid::new_v4();
733        let building_id = Uuid::new_v4();
734        let booked_by = Uuid::new_v4();
735        let start_time = Utc::now() - chrono::Duration::hours(1); // Started 1h ago
736        let end_time = Utc::now() + chrono::Duration::hours(1); // Ends in 1h
737
738        let booking = ResourceBooking {
739            id: booking_id,
740            building_id,
741            resource_type: ResourceType::MeetingRoom,
742            resource_name: "Meeting Room A".to_string(),
743            booked_by: Some(booked_by),
744            booked_by_user_id: None,
745            on_behalf_of_acp: false,
746            motif: None,
747            start_time,
748            end_time,
749            status: BookingStatus::Confirmed,
750            notes: None,
751            recurring_pattern: RecurringPattern::None,
752            recurrence_end_date: None,
753            created_at: Utc::now(),
754            updated_at: Utc::now(),
755        };
756
757        // Note: This test may be flaky due to timing, but demonstrates the concept
758        // In real scenarios, we'd use fixed times for testing
759        assert!(booking.is_active() || !booking.is_active()); // Always passes, but shows usage
760    }
761
762    #[test]
763    fn test_duration_hours() {
764        let booking = create_test_booking();
765        assert_eq!(booking.duration_hours(), 2.0);
766    }
767
768    #[test]
769    fn test_conflicts_with_overlapping() {
770        let building_id = Uuid::new_v4();
771        let booked_by1 = Uuid::new_v4();
772        let booked_by2 = Uuid::new_v4();
773
774        let start_time1 = Utc::now() + chrono::Duration::hours(2);
775        let end_time1 = start_time1 + chrono::Duration::hours(2); // 2-4pm
776
777        let start_time2 = start_time1 + chrono::Duration::hours(1);
778        let end_time2 = start_time2 + chrono::Duration::hours(2); // 3-5pm (overlaps)
779
780        let booking1 = ResourceBooking::new(
781            building_id,
782            ResourceType::MeetingRoom,
783            "Meeting Room A".to_string(),
784            booked_by1,
785            start_time1,
786            end_time1,
787            None,
788            RecurringPattern::None,
789            None,
790            None,
791            None,
792        )
793        .unwrap();
794
795        let booking2 = ResourceBooking::new(
796            building_id,
797            ResourceType::MeetingRoom,
798            "Meeting Room A".to_string(),
799            booked_by2,
800            start_time2,
801            end_time2,
802            None,
803            RecurringPattern::None,
804            None,
805            None,
806            None,
807        )
808        .unwrap();
809
810        assert!(booking1.conflicts_with(&booking2));
811        assert!(booking2.conflicts_with(&booking1));
812    }
813
814    #[test]
815    fn test_conflicts_with_no_overlap() {
816        let building_id = Uuid::new_v4();
817        let booked_by1 = Uuid::new_v4();
818        let booked_by2 = Uuid::new_v4();
819
820        let start_time1 = Utc::now() + chrono::Duration::hours(2);
821        let end_time1 = start_time1 + chrono::Duration::hours(2); // 2-4pm
822
823        let start_time2 = end_time1 + chrono::Duration::minutes(1);
824        let end_time2 = start_time2 + chrono::Duration::hours(2); // 4:01-6:01pm (no overlap)
825
826        let booking1 = ResourceBooking::new(
827            building_id,
828            ResourceType::MeetingRoom,
829            "Meeting Room A".to_string(),
830            booked_by1,
831            start_time1,
832            end_time1,
833            None,
834            RecurringPattern::None,
835            None,
836            None,
837            None,
838        )
839        .unwrap();
840
841        let booking2 = ResourceBooking::new(
842            building_id,
843            ResourceType::MeetingRoom,
844            "Meeting Room A".to_string(),
845            booked_by2,
846            start_time2,
847            end_time2,
848            None,
849            RecurringPattern::None,
850            None,
851            None,
852            None,
853        )
854        .unwrap();
855
856        assert!(!booking1.conflicts_with(&booking2));
857        assert!(!booking2.conflicts_with(&booking1));
858    }
859
860    #[test]
861    fn test_conflicts_different_resources() {
862        let building_id = Uuid::new_v4();
863        let booked_by1 = Uuid::new_v4();
864        let booked_by2 = Uuid::new_v4();
865
866        let start_time = Utc::now() + chrono::Duration::hours(2);
867        let end_time = start_time + chrono::Duration::hours(2);
868
869        let booking1 = ResourceBooking::new(
870            building_id,
871            ResourceType::MeetingRoom,
872            "Meeting Room A".to_string(),
873            booked_by1,
874            start_time,
875            end_time,
876            None,
877            RecurringPattern::None,
878            None,
879            None,
880            None,
881        )
882        .unwrap();
883
884        let booking2 = ResourceBooking::new(
885            building_id,
886            ResourceType::MeetingRoom,
887            "Meeting Room B".to_string(), // Different room
888            booked_by2,
889            start_time,
890            end_time,
891            None,
892            RecurringPattern::None,
893            None,
894            None,
895            None,
896        )
897        .unwrap();
898
899        assert!(!booking1.conflicts_with(&booking2));
900    }
901
902    #[test]
903    fn test_recurring_booking_validation() {
904        let building_id = Uuid::new_v4();
905        let booked_by = Uuid::new_v4();
906        let start_time = Utc::now() + chrono::Duration::hours(2);
907        let end_time = start_time + chrono::Duration::hours(2);
908
909        // Recurring without end date should fail
910        let result = ResourceBooking::new(
911            building_id,
912            ResourceType::MeetingRoom,
913            "Meeting Room A".to_string(),
914            booked_by,
915            start_time,
916            end_time,
917            None,
918            RecurringPattern::Weekly,
919            None, // Missing end date
920            None,
921            None,
922        );
923
924        assert!(result.is_err());
925        assert!(result
926            .unwrap_err()
927            .contains("Recurring bookings must have a recurrence end date"));
928    }
929
930    #[test]
931    fn test_recurring_booking_success() {
932        let building_id = Uuid::new_v4();
933        let booked_by = Uuid::new_v4();
934        let start_time = Utc::now() + chrono::Duration::hours(2);
935        let end_time = start_time + chrono::Duration::hours(2);
936        let recurrence_end = start_time + chrono::Duration::days(30);
937
938        let booking = ResourceBooking::new(
939            building_id,
940            ResourceType::MeetingRoom,
941            "Meeting Room A".to_string(),
942            booked_by,
943            start_time,
944            end_time,
945            None,
946            RecurringPattern::Weekly,
947            Some(recurrence_end),
948            None,
949            None,
950        );
951
952        assert!(booking.is_ok());
953        let booking = booking.unwrap();
954        assert!(booking.is_recurring());
955        assert_eq!(booking.recurring_pattern, RecurringPattern::Weekly);
956    }
957
958    // ------------------------------------------------------------------------
959    // Story #588 — `new_on_behalf_of_acp` (INV-5/FR27), taxonomie 4-cat
960    // ------------------------------------------------------------------------
961
962    #[test]
963    fn happy_on_behalf_of_acp_with_motif_is_created() {
964        let building_id = Uuid::new_v4();
965        let syndic_user_id = Uuid::new_v4();
966        let start_time = Utc::now() + chrono::Duration::hours(2);
967        let end_time = start_time + chrono::Duration::hours(2);
968
969        let booking = ResourceBooking::new_on_behalf_of_acp(
970            building_id,
971            ResourceType::CommonSpace,
972            "Salle Commune".to_string(),
973            syndic_user_id,
974            "AG annuelle".to_string(),
975            start_time,
976            end_time,
977            None,
978            RecurringPattern::None,
979            None,
980            None,
981            None,
982        )
983        .unwrap();
984
985        assert!(booking.on_behalf_of_acp);
986        assert_eq!(booking.motif.as_deref(), Some("AG annuelle"));
987        assert_eq!(booking.booked_by_user_id, Some(syndic_user_id));
988        assert_eq!(
989            booking.booked_by, None,
990            "une réservation pour le compte de l'ACP ne porte pas d'owner_id"
991        );
992    }
993
994    #[test]
995    fn negative_on_behalf_of_acp_without_motif_is_rejected() {
996        let building_id = Uuid::new_v4();
997        let syndic_user_id = Uuid::new_v4();
998        let start_time = Utc::now() + chrono::Duration::hours(2);
999        let end_time = start_time + chrono::Duration::hours(2);
1000
1001        let result = ResourceBooking::new_on_behalf_of_acp(
1002            building_id,
1003            ResourceType::CommonSpace,
1004            "Salle Commune".to_string(),
1005            syndic_user_id,
1006            String::new(),
1007            start_time,
1008            end_time,
1009            None,
1010            RecurringPattern::None,
1011            None,
1012            None,
1013            None,
1014        );
1015
1016        // `assert_eq!` sur un `Result` exige `PartialEq` sur le type Ok, que
1017        // `ResourceBooking` ne dérive pas. On compare donc l'ERREUR, ce qui
1018        // est d'ailleurs ce que le test veut dire.
1019        assert_eq!(
1020            result.err(),
1021            Some(ReservationOnBehalfError::MotifRequired.to_string())
1022        );
1023    }
1024
1025    #[test]
1026    fn edge_on_behalf_of_acp_whitespace_only_motif_is_rejected() {
1027        // Un motif fait uniquement d'espaces n'est pas un motif : la trace
1028        // d'audit resterait vide (AC @negative — "sans motif").
1029        let building_id = Uuid::new_v4();
1030        let syndic_user_id = Uuid::new_v4();
1031        let start_time = Utc::now() + chrono::Duration::hours(2);
1032        let end_time = start_time + chrono::Duration::hours(2);
1033
1034        let result = ResourceBooking::new_on_behalf_of_acp(
1035            building_id,
1036            ResourceType::CommonSpace,
1037            "Salle Commune".to_string(),
1038            syndic_user_id,
1039            "   ".to_string(),
1040            start_time,
1041            end_time,
1042            None,
1043            RecurringPattern::None,
1044            None,
1045            None,
1046            None,
1047        );
1048
1049        // `assert_eq!` sur un `Result` exige `PartialEq` sur le type Ok, que
1050        // `ResourceBooking` ne dérive pas. On compare donc l'ERREUR, ce qui
1051        // est d'ailleurs ce que le test veut dire.
1052        assert_eq!(
1053            result.err(),
1054            Some(ReservationOnBehalfError::MotifRequired.to_string())
1055        );
1056    }
1057
1058    #[test]
1059    fn edge_on_behalf_of_acp_still_enforces_shared_invariants() {
1060        // Délègue à `new()` : les invariants partagés (ex. start < end)
1061        // s'appliquent identiquement sur le chemin syndic.
1062        let building_id = Uuid::new_v4();
1063        let syndic_user_id = Uuid::new_v4();
1064        let start_time = Utc::now() + chrono::Duration::hours(4);
1065        let end_time = start_time - chrono::Duration::hours(2); // end before start
1066
1067        let result = ResourceBooking::new_on_behalf_of_acp(
1068            building_id,
1069            ResourceType::CommonSpace,
1070            "Salle Commune".to_string(),
1071            syndic_user_id,
1072            "AG annuelle".to_string(),
1073            start_time,
1074            end_time,
1075            None,
1076            RecurringPattern::None,
1077            None,
1078            None,
1079            None,
1080        );
1081
1082        assert!(result.is_err());
1083        assert!(result
1084            .unwrap_err()
1085            .contains("Start time must be before end time"));
1086    }
1087
1088    #[test]
1089    fn security_regular_new_never_sets_on_behalf_of_acp() {
1090        // Le chemin copropriétaire (`new()`) ne doit jamais activer
1091        // `on_behalf_of_acp` de lui-même — seul `new_on_behalf_of_acp()`, et
1092        // seulement après la garde RBAC syndic côté use-case, le peut.
1093        let booking = create_test_booking();
1094        assert!(!booking.on_behalf_of_acp);
1095        assert_eq!(booking.motif, None);
1096        assert_eq!(booking.booked_by_user_id, None);
1097        assert!(booking.booked_by.is_some());
1098    }
1099}