1use chrono::{DateTime, Duration, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
9pub enum MeetingType {
10 Ordinary, Extraordinary, }
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
16pub enum MeetingStatus {
17 Scheduled,
18 Completed,
19 Cancelled,
20}
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
25#[serde(rename_all = "snake_case")]
26pub enum MeetingMode {
27 InPerson,
28 Remote,
29 Hybrid,
30}
31
32impl MeetingMode {
33 pub fn from_db_string(s: &str) -> Result<Self, String> {
34 match s {
35 "in_person" => Ok(Self::InPerson),
36 "remote" => Ok(Self::Remote),
37 "hybrid" => Ok(Self::Hybrid),
38 other => Err(format!("Unknown meeting mode: {other}")),
39 }
40 }
41
42 pub fn to_db_str(&self) -> &'static str {
43 match self {
44 Self::InPerson => "in_person",
45 Self::Remote => "remote",
46 Self::Hybrid => "hybrid",
47 }
48 }
49
50 fn requires_videoconf_url(&self) -> bool {
54 matches!(self, Self::Remote | Self::Hybrid)
55 }
56
57 pub fn requires_strong_vote_auth(&self) -> bool {
63 self.requires_videoconf_url()
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
71pub enum MeetingModeError {
72 #[error(
73 "La configuration de visioconférence (URL) est obligatoire pour une AG en mode {mode:?} \
74 (Art. 3.87 §1er CC)"
75 )]
76 VideoconfUrlRequired { mode: MeetingMode },
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct Meeting {
82 pub id: Uuid,
83
84 pub acp_id: Uuid,
91
92 pub organization_id: Uuid,
94 pub building_id: Uuid,
95 pub meeting_type: MeetingType,
96 pub title: String,
97 pub description: Option<String>,
98 pub scheduled_date: DateTime<Utc>,
99 pub location: String,
100 pub status: MeetingStatus,
101 pub agenda: Vec<String>,
102 pub attendees_count: Option<i32>,
103 pub quorum_validated: bool,
105 pub quorum_percentage: Option<f64>, pub total_quotas: Option<Decimal>, pub present_quotas: Option<Decimal>, pub is_second_convocation: bool, pub minutes_document_id: Option<Uuid>, pub minutes_sent_at: Option<DateTime<Utc>>, pub mode: MeetingMode,
115 pub videoconf_url: Option<String>,
118 pub created_at: DateTime<Utc>,
119 pub updated_at: DateTime<Utc>,
120}
121
122impl Meeting {
123 pub fn new(
124 acp_id: Uuid,
125 organization_id: Uuid,
126 building_id: Uuid,
127 meeting_type: MeetingType,
128 title: String,
129 description: Option<String>,
130 scheduled_date: DateTime<Utc>,
131 location: String,
132 ) -> Result<Self, String> {
133 if title.is_empty() {
134 return Err("Title cannot be empty".to_string());
135 }
136 if location.is_empty() {
137 return Err("Location cannot be empty".to_string());
138 }
139
140 let now = Utc::now();
141 Ok(Self {
142 id: Uuid::new_v4(),
143 acp_id,
144 organization_id,
145 building_id,
146 meeting_type,
147 title,
148 description,
149 scheduled_date,
150 location,
151 status: MeetingStatus::Scheduled,
152 agenda: Vec::new(),
153 attendees_count: None,
154 quorum_validated: false,
155 quorum_percentage: None,
156 total_quotas: None,
157 present_quotas: None,
158 is_second_convocation: false, minutes_document_id: None,
160 minutes_sent_at: None,
161 mode: MeetingMode::InPerson,
162 videoconf_url: None,
163 created_at: now,
164 updated_at: now,
165 })
166 }
167
168 pub fn set_mode(
175 &mut self,
176 mode: MeetingMode,
177 videoconf_url: Option<String>,
178 ) -> Result<(), MeetingModeError> {
179 let url_present = videoconf_url
180 .as_deref()
181 .map(|u| !u.trim().is_empty())
182 .unwrap_or(false);
183 if mode.requires_videoconf_url() && !url_present {
184 return Err(MeetingModeError::VideoconfUrlRequired { mode });
185 }
186 self.mode = mode;
187 self.videoconf_url = videoconf_url;
188 self.updated_at = Utc::now();
189 Ok(())
190 }
191
192 pub fn add_agenda_item(&mut self, item: String) -> Result<(), String> {
193 if item.trim().is_empty() {
199 return Err(
200 "Un point d'ordre du jour doit énoncer ce qui sera mis aux voix : \
201 un intitulé vide n'informe aucun copropriétaire (Art. 3.87 § 2 CC)."
202 .to_string(),
203 );
204 }
205 self.agenda.push(item);
206 self.updated_at = Utc::now();
207 Ok(())
208 }
209
210 pub fn complete_internal(&mut self) -> Result<(), String> {
228 match self.status {
229 MeetingStatus::Scheduled => {
230 self.status = MeetingStatus::Completed;
231 self.updated_at = Utc::now();
232 Ok(())
233 }
234 MeetingStatus::Completed => Err("Meeting is already completed".to_string()),
235 MeetingStatus::Cancelled => Err("Cannot complete a cancelled meeting".to_string()),
236 }
237 }
238
239 #[deprecated(
248 note = "Use complete_internal() after assert_can_complete(&checklist) — Track H Story H3"
249 )]
250 pub fn complete(&mut self, attendees_count: i32) -> Result<(), String> {
251 self.complete_internal()?;
252 self.attendees_count = Some(attendees_count);
253 Ok(())
254 }
255
256 pub fn assert_can_complete(
274 &self,
275 checklist: &MeetingCompletionChecklist,
276 ) -> Result<(), MeetingNotCompletableError> {
277 let mut missing: Vec<MissingInvariant> = Vec::new();
278
279 if !checklist.convocations_sent {
280 missing.push(MissingInvariant::ConvocationsNotSent);
281 }
282 if checklist.open_resolutions > 0 {
283 missing.push(MissingInvariant::VotesNotClosed {
284 open_resolutions: checklist.open_resolutions,
285 });
286 }
287 if !checklist.attendance_recorded {
288 missing.push(MissingInvariant::AttendanceNotRecorded);
289 }
290
291 let quotas_alternative_ok =
298 Self::quotas_three_quarters_reached(checklist.attended_quotas, checklist.total_quotas);
299 if !quotas_alternative_ok {
300 let quotas_half_ok =
302 Self::quotas_half_reached(checklist.attended_quotas, checklist.total_quotas);
303 if !quotas_half_ok {
304 missing.push(MissingInvariant::QuorumNotReached {
305 attended_quotas: checklist.attended_quotas,
306 total_quotas: checklist.total_quotas,
307 });
308 }
309 let heads_ok = Self::heads_majority_reached(
311 checklist.present_owners_count,
312 checklist.total_owners_count,
313 );
314 if !heads_ok {
315 missing.push(MissingInvariant::HeadCountQuorumNotReached {
316 present_owners_count: checklist.present_owners_count,
317 total_owners_count: checklist.total_owners_count,
318 });
319 }
320 }
321
322 if !checklist.minutes_draft_exists {
323 missing.push(MissingInvariant::MinutesDraftMissing);
324 }
325
326 if missing.is_empty() {
327 Ok(())
328 } else {
329 Err(MeetingNotCompletableError {
330 meeting_id: self.id,
331 missing,
332 })
333 }
334 }
335
336 pub fn cancel(&mut self) -> Result<(), String> {
337 match self.status {
338 MeetingStatus::Scheduled => {
339 self.status = MeetingStatus::Cancelled;
340 self.updated_at = Utc::now();
341 Ok(())
342 }
343 MeetingStatus::Completed => Err("Cannot cancel a completed meeting".to_string()),
344 MeetingStatus::Cancelled => Err("Meeting is already cancelled".to_string()),
345 }
346 }
347
348 pub fn reschedule(&mut self, new_date: DateTime<Utc>) -> Result<(), String> {
349 match self.status {
350 MeetingStatus::Scheduled | MeetingStatus::Cancelled => {
351 self.scheduled_date = new_date;
352 self.status = MeetingStatus::Scheduled;
353 self.updated_at = Utc::now();
354 Ok(())
355 }
356 MeetingStatus::Completed => Err("Cannot reschedule a completed meeting".to_string()),
357 }
358 }
359
360 pub fn is_upcoming(&self) -> bool {
361 self.status == MeetingStatus::Scheduled && self.scheduled_date > Utc::now()
362 }
363
364 pub fn quotas_three_quarters_reached(attended_quotas: Decimal, total_quotas: Decimal) -> bool {
381 total_quotas > Decimal::ZERO && attended_quotas * dec!(4) > total_quotas * dec!(3)
382 }
383
384 pub fn quotas_half_reached(attended_quotas: Decimal, total_quotas: Decimal) -> bool {
387 total_quotas > Decimal::ZERO && attended_quotas * dec!(2) >= total_quotas
388 }
389
390 pub fn heads_majority_reached(present_owners_count: i32, total_owners_count: i32) -> bool {
393 total_owners_count > 0 && present_owners_count * 2 > total_owners_count
394 }
395
396 pub fn double_quorum_reached(
400 attended_quotas: Decimal,
401 total_quotas: Decimal,
402 present_owners_count: i32,
403 total_owners_count: i32,
404 ) -> bool {
405 Self::quotas_three_quarters_reached(attended_quotas, total_quotas)
406 || (Self::quotas_half_reached(attended_quotas, total_quotas)
407 && Self::heads_majority_reached(present_owners_count, total_owners_count))
408 }
409
410 pub fn validate_quorum(
418 &mut self,
419 present_quotas: Decimal,
420 total_quotas: Decimal,
421 ) -> Result<bool, String> {
422 use rust_decimal::prelude::ToPrimitive;
423 if total_quotas <= Decimal::ZERO {
424 return Err("Total quotas must be positive".to_string());
425 }
426 if present_quotas < Decimal::ZERO {
427 return Err("Present quotas cannot be negative".to_string());
428 }
429 if present_quotas > total_quotas {
430 return Err("Present quotas cannot exceed total quotas".to_string());
431 }
432
433 let percentage = (present_quotas / total_quotas) * dec!(100);
434 let quorum_reached = percentage > dec!(50);
450
451 self.present_quotas = Some(present_quotas);
452 self.total_quotas = Some(total_quotas);
453 self.quorum_percentage = percentage.to_f64();
454 self.quorum_validated = quorum_reached;
455 self.updated_at = Utc::now();
456
457 Ok(quorum_reached)
458 }
459
460 pub fn check_quorum_for_voting(&self) -> Result<(), String> {
466 if self.is_second_convocation {
468 return Ok(());
469 }
470
471 if self.quorum_percentage.is_none() {
472 return Err("Quorum has not been validated yet (Art. 3.87 §5 CC)".to_string());
473 }
474 if !self.quorum_validated {
475 let pct = self.quorum_percentage.unwrap_or(0.0);
476 return Err(format!(
477 "Quorum not reached: {:.1}% present (>50% required, Art. 3.87 §5 CC). \
478 A second convocation is required.",
479 pct
480 ));
481 }
482 Ok(())
483 }
484
485 pub fn set_minutes_sent(&mut self, document_id: Uuid) -> Result<(), String> {
488 if self.status != MeetingStatus::Completed {
489 return Err("Minutes can only be sent after meeting is completed".to_string());
490 }
491 self.minutes_document_id = Some(document_id);
492 self.minutes_sent_at = Some(Utc::now());
493 self.updated_at = Utc::now();
494 Ok(())
495 }
496
497 pub fn is_minutes_overdue(&self) -> bool {
500 if self.status != MeetingStatus::Completed {
501 return false;
502 }
503 if self.minutes_sent_at.is_some() {
504 return false;
505 }
506 let deadline = self.updated_at + Duration::days(30);
508 Utc::now() > deadline
509 }
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
522#[serde(tag = "type")]
523pub enum MissingInvariant {
524 ConvocationsNotSent,
526 VotesNotClosed { open_resolutions: i32 },
528 AttendanceNotRecorded,
530 QuorumNotReached {
534 attended_quotas: Decimal,
535 total_quotas: Decimal,
536 },
537 HeadCountQuorumNotReached {
542 present_owners_count: i32,
543 total_owners_count: i32,
544 },
545 MinutesDraftMissing,
547}
548
549#[derive(Debug, Clone, PartialEq)]
561pub struct MeetingCompletionChecklist {
562 pub convocations_sent: bool,
563 pub open_resolutions: i32,
565 pub attendance_recorded: bool,
566 pub attended_quotas: Decimal,
568 pub total_quotas: Decimal,
570 pub present_owners_count: i32,
574 pub total_owners_count: i32,
577 pub minutes_draft_exists: bool,
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
592pub struct MeetingNotCompletableError {
593 pub meeting_id: Uuid,
594 pub missing: Vec<MissingInvariant>,
595}
596
597impl std::fmt::Display for MeetingNotCompletableError {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 write!(
600 f,
601 "Meeting {} not completable: {} missing invariant(s)",
602 self.meeting_id,
603 self.missing.len()
604 )
605 }
606}
607
608impl std::error::Error for MeetingNotCompletableError {}
609
610impl crate::domain::services::PieceDeGestion for Meeting {
611 fn acp_id(&self) -> Uuid {
612 self.acp_id
613 }
614}
615
616#[cfg(test)]
617#[allow(deprecated)] mod tests {
619 use super::*;
620 use chrono::Duration;
621
622 #[test]
623 fn test_create_meeting_success() {
624 let org_id = Uuid::new_v4();
625 let building_id = Uuid::new_v4();
626 let future_date = Utc::now() + Duration::days(30);
627
628 let meeting = Meeting::new(
629 Uuid::new_v4(), org_id,
631 building_id,
632 MeetingType::Ordinary,
633 "AGO 2024".to_string(),
634 Some("Assemblée générale ordinaire annuelle".to_string()),
635 future_date,
636 "Salle des fêtes".to_string(),
637 );
638
639 assert!(meeting.is_ok());
640 let meeting = meeting.unwrap();
641 assert_eq!(meeting.organization_id, org_id);
642 assert_eq!(meeting.status, MeetingStatus::Scheduled);
643 assert!(meeting.is_upcoming());
644 }
645
646 #[test]
647 fn test_add_agenda_item() {
648 let org_id = Uuid::new_v4();
649 let building_id = Uuid::new_v4();
650 let future_date = Utc::now() + Duration::days(30);
651
652 let mut meeting = Meeting::new(
653 Uuid::new_v4(), org_id,
655 building_id,
656 MeetingType::Ordinary,
657 "AGO 2024".to_string(),
658 None,
659 future_date,
660 "Salle des fêtes".to_string(),
661 )
662 .unwrap();
663
664 let result = meeting.add_agenda_item("Approbation des comptes".to_string());
665 assert!(result.is_ok());
666 assert_eq!(meeting.agenda.len(), 1);
667 }
668
669 #[test]
676 fn negative_un_point_dordre_du_jour_blanc_est_refuse_a_linscription() {
677 let mut meeting = Meeting::new(
678 Uuid::new_v4(),
679 Uuid::new_v4(),
680 Uuid::new_v4(),
681 MeetingType::Ordinary,
682 "AGO 2026".to_string(),
683 None,
684 Utc::now() + Duration::days(30),
685 "Salle des fêtes".to_string(),
686 )
687 .unwrap();
688
689 for blanc in ["", " ", "\t", "\n", " \t \n "] {
690 let erreur = meeting
691 .add_agenda_item(blanc.to_string())
692 .expect_err("un intitulé blanc ne doit pas s'inscrire : {blanc:?}");
693 assert!(
694 erreur.contains("3.87"),
695 "le refus doit citer l'article qui le fonde : {erreur}"
696 );
697 }
698 assert!(
699 meeting.agenda.is_empty(),
700 "aucun point blanc ne doit avoir ete inscrit : {:?}",
701 meeting.agenda
702 );
703 }
704
705 #[test]
706 fn test_complete_meeting() {
707 let org_id = Uuid::new_v4();
708 let building_id = Uuid::new_v4();
709 let future_date = Utc::now() + Duration::days(30);
710
711 let mut meeting = Meeting::new(
712 Uuid::new_v4(), org_id,
714 building_id,
715 MeetingType::Ordinary,
716 "AGO 2024".to_string(),
717 None,
718 future_date,
719 "Salle des fêtes".to_string(),
720 )
721 .unwrap();
722
723 let result = meeting.complete(45);
724 assert!(result.is_ok());
725 assert_eq!(meeting.status, MeetingStatus::Completed);
726 assert_eq!(meeting.attendees_count, Some(45));
727 assert!(!meeting.is_upcoming());
728 }
729
730 #[test]
731 fn test_complete_already_completed_fails() {
732 let org_id = Uuid::new_v4();
733 let building_id = Uuid::new_v4();
734 let future_date = Utc::now() + Duration::days(30);
735
736 let mut meeting = Meeting::new(
737 Uuid::new_v4(), org_id,
739 building_id,
740 MeetingType::Ordinary,
741 "AGO 2024".to_string(),
742 None,
743 future_date,
744 "Salle des fêtes".to_string(),
745 )
746 .unwrap();
747
748 meeting.complete(45).unwrap();
749 let result = meeting.complete(50);
750 assert!(result.is_err());
751 assert_eq!(meeting.attendees_count, Some(45)); }
753
754 #[test]
755 fn test_cancel_meeting() {
756 let org_id = Uuid::new_v4();
757 let building_id = Uuid::new_v4();
758 let future_date = Utc::now() + Duration::days(30);
759
760 let mut meeting = Meeting::new(
761 Uuid::new_v4(), org_id,
763 building_id,
764 MeetingType::Ordinary,
765 "AGO 2024".to_string(),
766 None,
767 future_date,
768 "Salle des fêtes".to_string(),
769 )
770 .unwrap();
771
772 let result = meeting.cancel();
773 assert!(result.is_ok());
774 assert_eq!(meeting.status, MeetingStatus::Cancelled);
775 }
776
777 #[test]
778 fn test_quorum_reached_above_50_percent() {
779 let org_id = Uuid::new_v4();
780 let building_id = Uuid::new_v4();
781 let future_date = Utc::now() + Duration::days(30);
782
783 let mut meeting = Meeting::new(
784 Uuid::new_v4(), org_id,
786 building_id,
787 MeetingType::Ordinary,
788 "AGO 2024".to_string(),
789 None,
790 future_date,
791 "Salle des fêtes".to_string(),
792 )
793 .unwrap();
794
795 let result = meeting.validate_quorum(dec!(600), dec!(1000));
797 assert!(result.is_ok());
798 assert!(result.unwrap());
799 assert!(meeting.quorum_validated);
800 assert!((meeting.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
801 }
802
803 #[test]
804 fn test_quorum_not_reached_at_50_percent_exact() {
805 let org_id = Uuid::new_v4();
806 let building_id = Uuid::new_v4();
807 let future_date = Utc::now() + Duration::days(30);
808
809 let mut meeting = Meeting::new(
810 Uuid::new_v4(), org_id,
812 building_id,
813 MeetingType::Ordinary,
814 "AGO 2024".to_string(),
815 None,
816 future_date,
817 "Salle des fêtes".to_string(),
818 )
819 .unwrap();
820
821 let result = meeting.validate_quorum(dec!(500), dec!(1000));
823 assert!(result.is_ok());
824 assert!(!result.unwrap());
825 assert!(!meeting.quorum_validated);
826 }
827
828 #[test]
829 fn test_quorum_not_reached_below_50_percent() {
830 let org_id = Uuid::new_v4();
831 let building_id = Uuid::new_v4();
832 let future_date = Utc::now() + Duration::days(30);
833
834 let mut meeting = Meeting::new(
835 Uuid::new_v4(), org_id,
837 building_id,
838 MeetingType::Ordinary,
839 "AGO 2024".to_string(),
840 None,
841 future_date,
842 "Salle des fêtes".to_string(),
843 )
844 .unwrap();
845
846 let result = meeting.validate_quorum(dec!(400), dec!(1000));
848 assert!(result.is_ok());
849 assert!(!result.unwrap());
850 assert!(!meeting.quorum_validated);
851 }
852
853 #[test]
854 fn test_check_quorum_blocks_vote_when_not_validated() {
855 let org_id = Uuid::new_v4();
856 let building_id = Uuid::new_v4();
857 let future_date = Utc::now() + Duration::days(30);
858
859 let meeting = Meeting::new(
860 Uuid::new_v4(), org_id,
862 building_id,
863 MeetingType::Ordinary,
864 "AGO 2024".to_string(),
865 None,
866 future_date,
867 "Salle des fêtes".to_string(),
868 )
869 .unwrap();
870
871 let result = meeting.check_quorum_for_voting();
872 assert!(result.is_err());
873 assert!(result.unwrap_err().contains("not been validated yet"));
874 }
875
876 #[test]
877 fn test_check_quorum_skipped_for_second_convocation() {
878 let org_id = Uuid::new_v4();
880 let building_id = Uuid::new_v4();
881 let future_date = Utc::now() + Duration::days(30);
882
883 let mut meeting = Meeting::new(
884 Uuid::new_v4(), org_id,
886 building_id,
887 MeetingType::Extraordinary,
888 "2e Convocation AGE".to_string(),
889 Some("Deuxième convocation - sans quorum".to_string()),
890 future_date,
891 "Salle des fêtes".to_string(),
892 )
893 .unwrap();
894
895 meeting.is_second_convocation = true;
897
898 let result = meeting.check_quorum_for_voting();
900 assert!(result.is_ok(), "2nd convocation should skip quorum check");
901 }
902
903 #[test]
904 fn test_check_quorum_blocks_vote_when_quorum_not_reached() {
905 let org_id = Uuid::new_v4();
906 let building_id = Uuid::new_v4();
907 let future_date = Utc::now() + Duration::days(30);
908
909 let mut meeting = Meeting::new(
910 Uuid::new_v4(), org_id,
912 building_id,
913 MeetingType::Ordinary,
914 "AGO 2024".to_string(),
915 None,
916 future_date,
917 "Salle des fêtes".to_string(),
918 )
919 .unwrap();
920
921 meeting.validate_quorum(dec!(400), dec!(1000)).unwrap();
922 let result = meeting.check_quorum_for_voting();
923 assert!(result.is_err());
924 assert!(result.unwrap_err().contains("second convocation"));
925 }
926
927 #[test]
928 fn test_quorum_invalid_total_quotas() {
929 let org_id = Uuid::new_v4();
930 let building_id = Uuid::new_v4();
931 let future_date = Utc::now() + Duration::days(30);
932
933 let mut meeting = Meeting::new(
934 Uuid::new_v4(), org_id,
936 building_id,
937 MeetingType::Ordinary,
938 "AGO 2024".to_string(),
939 None,
940 future_date,
941 "Salle des fêtes".to_string(),
942 )
943 .unwrap();
944
945 let result = meeting.validate_quorum(dec!(100), dec!(0));
946 assert!(result.is_err());
947 }
948
949 #[test]
955 fn happy_set_mode_in_person_without_url() {
956 let mut meeting = make_meeting_for_mode_tests();
957 assert!(meeting.set_mode(MeetingMode::InPerson, None).is_ok());
958 assert_eq!(meeting.mode, MeetingMode::InPerson);
959 assert!(meeting.videoconf_url.is_none());
960 }
961
962 #[test]
964 fn happy_set_mode_hybrid_with_url() {
965 let mut meeting = make_meeting_for_mode_tests();
966 let result = meeting.set_mode(
967 MeetingMode::Hybrid,
968 Some("https://meet.jit.si/koprogo-ago-2026".to_string()),
969 );
970 assert!(result.is_ok());
971 assert_eq!(meeting.mode, MeetingMode::Hybrid);
972 assert_eq!(
973 meeting.videoconf_url.as_deref(),
974 Some("https://meet.jit.si/koprogo-ago-2026")
975 );
976 }
977
978 #[test]
980 fn edge_set_mode_remote_with_blank_url_is_refused() {
981 let mut meeting = make_meeting_for_mode_tests();
982 let err = meeting
983 .set_mode(MeetingMode::Remote, Some(" ".to_string()))
984 .expect_err("une URL blanche ne configure rien");
985 assert_eq!(
986 err,
987 MeetingModeError::VideoconfUrlRequired {
988 mode: MeetingMode::Remote
989 }
990 );
991 }
992
993 #[test]
996 fn edge_set_mode_back_to_in_person_clears_url() {
997 let mut meeting = make_meeting_for_mode_tests();
998 meeting
999 .set_mode(
1000 MeetingMode::Remote,
1001 Some("https://meet.jit.si/x".to_string()),
1002 )
1003 .unwrap();
1004 assert!(meeting.set_mode(MeetingMode::InPerson, None).is_ok());
1005 assert!(meeting.videoconf_url.is_none());
1006 }
1007
1008 #[test]
1011 fn security_set_mode_empty_string_url_is_treated_as_missing() {
1012 let mut meeting = make_meeting_for_mode_tests();
1013 let err = meeting
1014 .set_mode(MeetingMode::Hybrid, Some(String::new()))
1015 .expect_err("une chaîne vide ne doit pas contourner l'obligation d'URL");
1016 assert_eq!(
1017 err,
1018 MeetingModeError::VideoconfUrlRequired {
1019 mode: MeetingMode::Hybrid
1020 }
1021 );
1022 }
1023
1024 #[test]
1027 fn negative_set_mode_hybrid_without_videoconf_url_is_rejected() {
1028 let mut meeting = make_meeting_for_mode_tests();
1029 let mode_before = meeting.mode;
1030 let err = meeting
1031 .set_mode(MeetingMode::Hybrid, None)
1032 .expect_err("hybrid sans URL doit échouer (422 côté AppError)");
1033 assert_eq!(
1034 err,
1035 MeetingModeError::VideoconfUrlRequired {
1036 mode: MeetingMode::Hybrid
1037 }
1038 );
1039 assert_eq!(
1040 meeting.mode, mode_before,
1041 "une tentative refusée ne doit pas muter l'entité"
1042 );
1043 assert!(meeting.videoconf_url.is_none());
1044 }
1045
1046 fn make_meeting_for_mode_tests() -> Meeting {
1047 let org_id = Uuid::new_v4();
1048 let building_id = Uuid::new_v4();
1049 let future_date = Utc::now() + Duration::days(30);
1050 Meeting::new(
1051 Uuid::new_v4(),
1052 org_id,
1053 building_id,
1054 MeetingType::Ordinary,
1055 "AGO 2026 hybride".to_string(),
1056 None,
1057 future_date,
1058 "Salle des fêtes".to_string(),
1059 )
1060 .unwrap()
1061 }
1062
1063 #[test]
1064 fn test_reschedule_meeting() {
1065 let org_id = Uuid::new_v4();
1066 let building_id = Uuid::new_v4();
1067 let future_date = Utc::now() + Duration::days(30);
1068
1069 let mut meeting = Meeting::new(
1070 Uuid::new_v4(), org_id,
1072 building_id,
1073 MeetingType::Ordinary,
1074 "AGO 2024".to_string(),
1075 None,
1076 future_date,
1077 "Salle des fêtes".to_string(),
1078 )
1079 .unwrap();
1080
1081 let new_date = Utc::now() + Duration::days(60);
1082 let result = meeting.reschedule(new_date);
1083 assert!(result.is_ok());
1084 assert_eq!(meeting.scheduled_date, new_date);
1085 }
1086
1087 #[test]
1088 fn test_set_minutes_sent_success() {
1089 let org_id = Uuid::new_v4();
1091 let building_id = Uuid::new_v4();
1092 let future_date = Utc::now() + Duration::days(30);
1093 let doc_id = Uuid::new_v4();
1094
1095 let mut meeting = Meeting::new(
1096 Uuid::new_v4(), org_id,
1098 building_id,
1099 MeetingType::Ordinary,
1100 "AGO 2024".to_string(),
1101 None,
1102 future_date,
1103 "Salle des fêtes".to_string(),
1104 )
1105 .unwrap();
1106
1107 meeting.complete(45).unwrap();
1109 let result = meeting.set_minutes_sent(doc_id);
1110
1111 assert!(result.is_ok());
1113 assert_eq!(meeting.minutes_document_id, Some(doc_id));
1114 assert!(meeting.minutes_sent_at.is_some());
1115 }
1116
1117 #[test]
1118 fn test_set_minutes_sent_before_completion_fails() {
1119 let org_id = Uuid::new_v4();
1121 let building_id = Uuid::new_v4();
1122 let future_date = Utc::now() + Duration::days(30);
1123 let doc_id = Uuid::new_v4();
1124
1125 let mut meeting = Meeting::new(
1126 Uuid::new_v4(), org_id,
1128 building_id,
1129 MeetingType::Ordinary,
1130 "AGO 2024".to_string(),
1131 None,
1132 future_date,
1133 "Salle des fêtes".to_string(),
1134 )
1135 .unwrap();
1136
1137 let result = meeting.set_minutes_sent(doc_id);
1139
1140 assert!(result.is_err());
1142 assert_eq!(
1143 result.unwrap_err(),
1144 "Minutes can only be sent after meeting is completed"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_is_minutes_overdue_not_completed() {
1150 let org_id = Uuid::new_v4();
1152 let building_id = Uuid::new_v4();
1153 let future_date = Utc::now() + Duration::days(30);
1154
1155 let meeting = Meeting::new(
1156 Uuid::new_v4(), org_id,
1158 building_id,
1159 MeetingType::Ordinary,
1160 "AGO 2024".to_string(),
1161 None,
1162 future_date,
1163 "Salle des fêtes".to_string(),
1164 )
1165 .unwrap();
1166
1167 assert!(!meeting.is_minutes_overdue()); }
1170
1171 #[test]
1172 fn test_is_minutes_overdue_sent() {
1173 let org_id = Uuid::new_v4();
1175 let building_id = Uuid::new_v4();
1176 let future_date = Utc::now() + Duration::days(30);
1177 let doc_id = Uuid::new_v4();
1178
1179 let mut meeting = Meeting::new(
1180 Uuid::new_v4(), org_id,
1182 building_id,
1183 MeetingType::Ordinary,
1184 "AGO 2024".to_string(),
1185 None,
1186 future_date,
1187 "Salle des fêtes".to_string(),
1188 )
1189 .unwrap();
1190
1191 meeting.complete(45).unwrap();
1193 meeting.set_minutes_sent(doc_id).unwrap();
1194
1195 assert!(!meeting.is_minutes_overdue()); }
1198
1199 #[test]
1200 fn test_is_minutes_overdue_past_30_days() {
1201 let org_id = Uuid::new_v4();
1203 let building_id = Uuid::new_v4();
1204 let future_date = Utc::now() + Duration::days(30);
1205
1206 let mut meeting = Meeting::new(
1207 Uuid::new_v4(), org_id,
1209 building_id,
1210 MeetingType::Ordinary,
1211 "AGO 2024".to_string(),
1212 None,
1213 future_date,
1214 "Salle des fêtes".to_string(),
1215 )
1216 .unwrap();
1217
1218 meeting.complete(45).unwrap();
1220 meeting.updated_at = Utc::now() - Duration::days(31);
1221
1222 assert!(meeting.is_minutes_overdue()); }
1225
1226 #[test]
1227 fn test_is_minutes_overdue_within_30_days() {
1228 let org_id = Uuid::new_v4();
1230 let building_id = Uuid::new_v4();
1231 let future_date = Utc::now() + Duration::days(30);
1232
1233 let mut meeting = Meeting::new(
1234 Uuid::new_v4(), org_id,
1236 building_id,
1237 MeetingType::Ordinary,
1238 "AGO 2024".to_string(),
1239 None,
1240 future_date,
1241 "Salle des fêtes".to_string(),
1242 )
1243 .unwrap();
1244
1245 meeting.complete(45).unwrap();
1247
1248 assert!(!meeting.is_minutes_overdue()); }
1251}
1252
1253#[cfg(test)]
1258#[allow(deprecated)] mod assert_can_complete_tests {
1260 use super::*;
1261 use chrono::Duration;
1262
1263 fn make_meeting() -> Meeting {
1265 let org_id = Uuid::new_v4();
1266 let building_id = Uuid::new_v4();
1267 Meeting::new(
1268 Uuid::new_v4(), org_id,
1270 building_id,
1271 MeetingType::Ordinary,
1272 "AGO Track H Story H3".to_string(),
1273 None,
1274 Utc::now() + Duration::days(30),
1275 "Salle des fêtes".to_string(),
1276 )
1277 .unwrap()
1278 }
1279
1280 fn checklist_all_ok() -> MeetingCompletionChecklist {
1282 MeetingCompletionChecklist {
1283 convocations_sent: true,
1284 open_resolutions: 0,
1285 attendance_recorded: true,
1286 attended_quotas: dec!(600),
1287 total_quotas: dec!(1000),
1288 present_owners_count: 6,
1290 total_owners_count: 10,
1291 minutes_draft_exists: true,
1292 }
1293 }
1294
1295 #[test]
1300 fn happy_all_invariants_ok_returns_ok() {
1301 let m = make_meeting();
1303 let c = checklist_all_ok();
1304 assert!(m.assert_can_complete(&c).is_ok());
1305 }
1306
1307 #[test]
1308 fn happy_then_complete_internal_transitions_to_completed() {
1309 let mut m = make_meeting();
1311 let c = checklist_all_ok();
1312 m.assert_can_complete(&c).unwrap();
1313 m.complete_internal().unwrap();
1314 assert_eq!(m.status, MeetingStatus::Completed);
1315 }
1316
1317 #[test]
1322 fn edge_quorum_quotas_exact_50_percent_accepted_with_heads_ok() {
1323 let m = make_meeting();
1327 let c = MeetingCompletionChecklist {
1328 attended_quotas: dec!(500),
1329 total_quotas: dec!(1000),
1330 ..checklist_all_ok()
1331 };
1332 assert!(m.assert_can_complete(&c).is_ok());
1333 }
1334
1335 #[test]
1336 fn edge_quorum_just_above_50_percent_is_accepted() {
1337 let m = make_meeting();
1339 let c = MeetingCompletionChecklist {
1340 attended_quotas: dec!(500.0001),
1341 total_quotas: dec!(1000),
1342 ..checklist_all_ok()
1343 };
1344 assert!(m.assert_can_complete(&c).is_ok());
1345 }
1346
1347 #[test]
1348 fn edge_quorum_basis_10000_just_above_50_percent_is_accepted() {
1349 let m = make_meeting();
1351 let c = MeetingCompletionChecklist {
1352 attended_quotas: dec!(5000.5),
1353 total_quotas: dec!(10000),
1354 ..checklist_all_ok()
1355 };
1356 assert!(m.assert_can_complete(&c).is_ok());
1357 }
1358
1359 #[test]
1360 fn edge_open_resolution_count_one_is_blocking() {
1361 let m = make_meeting();
1365 let c = MeetingCompletionChecklist {
1366 open_resolutions: 1,
1367 ..checklist_all_ok()
1368 };
1369 let err = m.assert_can_complete(&c).unwrap_err();
1370 assert!(err.missing.iter().any(|m| matches!(
1371 m,
1372 MissingInvariant::VotesNotClosed {
1373 open_resolutions: 1
1374 }
1375 )));
1376 }
1377
1378 #[test]
1379 fn edge_total_quotas_zero_returns_quorum_not_reached_not_panic() {
1380 let m = make_meeting();
1382 let c = MeetingCompletionChecklist {
1383 attended_quotas: dec!(0),
1384 total_quotas: dec!(0),
1385 ..checklist_all_ok()
1386 };
1387 let err = m.assert_can_complete(&c).unwrap_err();
1388 assert!(err.missing.iter().any(|m| matches!(
1389 m,
1390 MissingInvariant::QuorumNotReached { attended_quotas, total_quotas }
1391 if *attended_quotas == dec!(0) && *total_quotas == dec!(0)
1392 )));
1393 }
1394
1395 #[test]
1400 fn security_assert_is_pure_no_state_mutation() {
1401 let m = make_meeting();
1404 let scheduled_status_before = m.status.clone();
1405 let _ = m.assert_can_complete(&checklist_all_ok());
1406 assert_eq!(m.status, scheduled_status_before);
1407
1408 let c = MeetingCompletionChecklist {
1410 convocations_sent: false,
1411 ..checklist_all_ok()
1412 };
1413 let _ = m.assert_can_complete(&c);
1414 assert_eq!(m.status, scheduled_status_before);
1415 }
1416
1417 #[test]
1418 fn security_attendees_count_param_is_ignored_source_of_truth_is_checklist() {
1419 let mut m = make_meeting();
1425 m.attendees_count = Some(99_999); let c = MeetingCompletionChecklist {
1427 attended_quotas: dec!(100), total_quotas: dec!(1000),
1429 ..checklist_all_ok()
1430 };
1431 let err = m.assert_can_complete(&c).unwrap_err();
1432 assert!(err
1433 .missing
1434 .iter()
1435 .any(|m| matches!(m, MissingInvariant::QuorumNotReached { .. })));
1436 }
1437
1438 #[test]
1443 fn negative_all_invariants_missing_returns_all_six() {
1444 let m = make_meeting();
1447 let c = MeetingCompletionChecklist {
1448 convocations_sent: false,
1449 open_resolutions: 3,
1450 attendance_recorded: false,
1451 attended_quotas: dec!(0),
1452 total_quotas: dec!(1000),
1453 present_owners_count: 0,
1454 total_owners_count: 10,
1455 minutes_draft_exists: false,
1456 };
1457 let err = m.assert_can_complete(&c).unwrap_err();
1458 assert_eq!(err.missing.len(), 6);
1462 assert!(matches!(
1463 err.missing[0],
1464 MissingInvariant::ConvocationsNotSent
1465 ));
1466 assert!(matches!(
1467 err.missing[1],
1468 MissingInvariant::VotesNotClosed {
1469 open_resolutions: 3
1470 }
1471 ));
1472 assert!(matches!(
1473 err.missing[2],
1474 MissingInvariant::AttendanceNotRecorded
1475 ));
1476 assert!(matches!(
1477 err.missing[3],
1478 MissingInvariant::QuorumNotReached { .. }
1479 ));
1480 assert!(matches!(
1481 err.missing[4],
1482 MissingInvariant::HeadCountQuorumNotReached { .. }
1483 ));
1484 assert!(matches!(
1485 err.missing[5],
1486 MissingInvariant::MinutesDraftMissing
1487 ));
1488 }
1489
1490 #[test]
1491 fn negative_only_convocations_missing_returns_one_invariant() {
1492 let m = make_meeting();
1493 let c = MeetingCompletionChecklist {
1494 convocations_sent: false,
1495 ..checklist_all_ok()
1496 };
1497 let err = m.assert_can_complete(&c).unwrap_err();
1498 assert_eq!(err.missing.len(), 1);
1499 assert_eq!(err.missing[0], MissingInvariant::ConvocationsNotSent);
1500 assert_eq!(err.meeting_id, m.id);
1501 }
1502
1503 #[test]
1504 fn negative_only_minutes_missing_returns_one_invariant() {
1505 let m = make_meeting();
1506 let c = MeetingCompletionChecklist {
1507 minutes_draft_exists: false,
1508 ..checklist_all_ok()
1509 };
1510 let err = m.assert_can_complete(&c).unwrap_err();
1511 assert_eq!(err.missing.len(), 1);
1512 assert_eq!(err.missing[0], MissingInvariant::MinutesDraftMissing);
1513 }
1514
1515 #[test]
1516 fn negative_display_does_not_leak_business_internals() {
1517 const IDENTIFIANT_DE_TEST: &str = "7bcd5e2f-8a9b-4c7d-9e5f-2a3b6c8d9e5f";
1533
1534 assert!(
1544 !IDENTIFIANT_DE_TEST.contains("400") && !IDENTIFIANT_DE_TEST.contains("1000"),
1545 "l'identifiant de ce test contient une des sous-chaînes qu'on \
1546 vérifie justement être absentes : l'échec porterait sur \
1547 l'identifiant, pas sur le Display. Choisissez-en un autre, et \
1548 surtout pas un identifiant tiré au hasard (#777)."
1549 );
1550
1551 let err = MeetingNotCompletableError {
1552 meeting_id: Uuid::parse_str(IDENTIFIANT_DE_TEST).expect("UUID de test valide"),
1553 missing: vec![MissingInvariant::QuorumNotReached {
1554 attended_quotas: dec!(400),
1555 total_quotas: dec!(1000),
1556 }],
1557 };
1558 let s = format!("{}", err);
1559 assert!(s.contains("not completable"));
1560 assert!(!s.contains("400"));
1562 assert!(!s.contains("1000"));
1563 }
1564
1565 #[test]
1566 fn negative_complete_internal_on_completed_meeting_fails() {
1567 let mut m = make_meeting();
1570 m.assert_can_complete(&checklist_all_ok()).unwrap();
1571 m.complete_internal().unwrap();
1572 let res = m.complete_internal();
1574 assert!(res.is_err());
1575 assert_eq!(m.status, MeetingStatus::Completed);
1576 }
1577
1578 #[test]
1584 fn happy_double_quorum_primary_heads_and_quotas() {
1585 let m = make_meeting();
1586 let c = MeetingCompletionChecklist {
1587 attended_quotas: dec!(500), total_quotas: dec!(1000),
1589 present_owners_count: 6, total_owners_count: 10,
1591 ..checklist_all_ok()
1592 };
1593 assert!(m.assert_can_complete(&c).is_ok());
1594 }
1595
1596 #[test]
1598 fn happy_double_quorum_alternative_three_quarters() {
1599 let m = make_meeting();
1600 let c = MeetingCompletionChecklist {
1601 attended_quotas: dec!(760), total_quotas: dec!(1000),
1603 present_owners_count: 2, total_owners_count: 10,
1605 ..checklist_all_ok()
1606 };
1607 assert!(m.assert_can_complete(&c).is_ok());
1608 }
1609
1610 #[test]
1613 fn edge_heads_exactly_50_percent_rejected() {
1614 let m = make_meeting();
1615 let c = MeetingCompletionChecklist {
1616 attended_quotas: dec!(600),
1617 total_quotas: dec!(1000),
1618 present_owners_count: 5, total_owners_count: 10,
1620 ..checklist_all_ok()
1621 };
1622 let err = m.assert_can_complete(&c).unwrap_err();
1623 assert!(err
1624 .missing
1625 .iter()
1626 .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1627 assert!(!err
1628 .missing
1629 .iter()
1630 .any(|x| matches!(x, MissingInvariant::QuorumNotReached { .. })));
1631 }
1632
1633 #[test]
1636 fn edge_three_quarters_boundary() {
1637 let m = make_meeting();
1638 let exactly = MeetingCompletionChecklist {
1639 attended_quotas: dec!(750), total_quotas: dec!(1000),
1641 present_owners_count: 3, total_owners_count: 10,
1643 ..checklist_all_ok()
1644 };
1645 assert!(m.assert_can_complete(&exactly).is_err());
1646
1647 let above = MeetingCompletionChecklist {
1648 attended_quotas: dec!(750.001), total_quotas: dec!(1000),
1650 present_owners_count: 3,
1651 total_owners_count: 10,
1652 ..checklist_all_ok()
1653 };
1654 assert!(m.assert_can_complete(&above).is_ok());
1655 }
1656
1657 #[test]
1660 fn security_head_count_source_is_checklist_not_forged_field() {
1661 let mut m = make_meeting();
1662 m.attendees_count = Some(99_999); let c = MeetingCompletionChecklist {
1664 attended_quotas: dec!(600), total_quotas: dec!(1000),
1666 present_owners_count: 1, total_owners_count: 10,
1668 ..checklist_all_ok()
1669 };
1670 let err = m.assert_can_complete(&c).unwrap_err();
1671 assert!(err
1672 .missing
1673 .iter()
1674 .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1675 }
1676
1677 #[test]
1679 fn negative_only_head_count_quorum_missing() {
1680 let m = make_meeting();
1681 let c = MeetingCompletionChecklist {
1682 attended_quotas: dec!(600),
1683 total_quotas: dec!(1000),
1684 present_owners_count: 4, total_owners_count: 10,
1686 ..checklist_all_ok()
1687 };
1688 let err = m.assert_can_complete(&c).unwrap_err();
1689 assert_eq!(err.missing.len(), 1);
1690 assert!(matches!(
1691 err.missing[0],
1692 MissingInvariant::HeadCountQuorumNotReached {
1693 present_owners_count: 4,
1694 total_owners_count: 10
1695 }
1696 ));
1697 }
1698
1699 #[test]
1701 fn negative_zero_total_owners_is_head_quorum_not_reached() {
1702 let m = make_meeting();
1703 let c = MeetingCompletionChecklist {
1704 attended_quotas: dec!(600),
1705 total_quotas: dec!(1000),
1706 present_owners_count: 0,
1707 total_owners_count: 0,
1708 ..checklist_all_ok()
1709 };
1710 let err = m.assert_can_complete(&c).unwrap_err();
1711 assert!(err
1712 .missing
1713 .iter()
1714 .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1715 }
1716}