1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
21pub enum BookingStatus {
22 Pending, Confirmed, Cancelled, Completed, NoShow, }
28
29#[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#[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, pub booked_by: Option<Uuid>, pub booked_by_user_id: Option<Uuid>, pub on_behalf_of_acp: bool,
71 pub motif: Option<String>, 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>>, pub created_at: DateTime<Utc>,
79 pub updated_at: DateTime<Utc>,
80}
81
82#[derive(Debug, Clone, PartialEq)]
87pub enum ReservationOnBehalfError {
88 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
107impl From<ReservationOnBehalfError> for String {
111 fn from(e: ReservationOnBehalfError) -> String {
112 e.to_string()
113 }
114}
115
116impl ResourceBooking {
117 pub const DEFAULT_MAX_DURATION_HOURS: i64 = 4;
119
120 pub const DEFAULT_MAX_ADVANCE_DAYS: i64 = 30;
122
123 pub const MIN_DURATION_MINUTES: i64 = 30;
125
126 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 if resource_name.len() < 3 || resource_name.len() > 100 {
164 return Err("Resource name must be 3-100 characters".to_string());
165 }
166
167 if start_time >= end_time {
169 return Err("Start time must be before end time".to_string());
170 }
171
172 let now = Utc::now();
174 if start_time <= now {
175 return Err("Cannot book resources in the past".to_string());
176 }
177
178 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 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 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 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 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, notes,
238 recurring_pattern,
239 recurrence_end_date,
240 created_at: now,
241 updated_at: now,
242 })
243 }
244
245 #[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 pub fn cancel(&mut self, canceller_id: Uuid) -> Result<(), String> {
316 if self.booked_by != Some(canceller_id) {
318 return Err("Only the booking owner can cancel this booking".to_string());
319 }
320
321 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 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 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 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 pub fn update_details(
394 &mut self,
395 resource_name: Option<String>,
396 notes: Option<String>,
397 ) -> Result<(), String> {
398 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 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 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 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 pub fn is_past(&self) -> bool {
437 Utc::now() >= self.end_time
438 }
439
440 pub fn is_future(&self) -> bool {
442 Utc::now() < self.start_time
443 }
444
445 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 pub fn conflicts_with(&self, other: &ResourceBooking) -> bool {
461 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 if !matches!(
471 other.status,
472 BookingStatus::Pending | BookingStatus::Confirmed
473 ) {
474 return false;
475 }
476
477 self.start_time < other.end_time && other.start_time < self.end_time
479 }
480
481 pub fn is_modifiable(&self) -> bool {
483 matches!(
484 self.status,
485 BookingStatus::Pending | BookingStatus::Confirmed
486 )
487 }
488
489 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(), 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); 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); 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); 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); 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 #[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); let end_time = Utc::now() + chrono::Duration::hours(1); 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 assert!(booking.is_active() || !booking.is_active()); }
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); let start_time2 = start_time1 + chrono::Duration::hours(1);
778 let end_time2 = start_time2 + chrono::Duration::hours(2); 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); let start_time2 = end_time1 + chrono::Duration::minutes(1);
824 let end_time2 = start_time2 + chrono::Duration::hours(2); 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(), 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 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, 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 #[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!(
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 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!(
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 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); 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 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}