koprogo_api/application/use_cases/
meeting_use_cases.rs

1use crate::application::dto::{
2    AddAgendaItemRequest, CompleteMeetingRequest, CreateMeetingRequest, MeetingResponse,
3    PageRequest, UpdateMeetingRequest,
4};
5use crate::application::ports::MeetingRepository;
6use crate::domain::entities::Meeting;
7use chrono::Duration;
8use std::sync::Arc;
9use uuid::Uuid;
10
11pub struct MeetingUseCases {
12    repository: Arc<dyn MeetingRepository>,
13    convocation_use_cases: Option<Arc<crate::application::use_cases::ConvocationUseCases>>,
14}
15
16impl MeetingUseCases {
17    pub fn new(repository: Arc<dyn MeetingRepository>) -> Self {
18        Self {
19            repository,
20            convocation_use_cases: None,
21        }
22    }
23
24    /// Create MeetingUseCases with ConvocationUseCases for automatic 2nd convocation scheduling
25    pub fn new_with_convocation(
26        repository: Arc<dyn MeetingRepository>,
27        convocation_use_cases: Arc<crate::application::use_cases::ConvocationUseCases>,
28    ) -> Self {
29        Self {
30            repository,
31            convocation_use_cases: Some(convocation_use_cases),
32        }
33    }
34
35    pub async fn create_meeting(
36        &self,
37        request: CreateMeetingRequest,
38    ) -> Result<MeetingResponse, String> {
39        let meeting = Meeting::new(
40            request.organization_id,
41            request.building_id,
42            request.meeting_type,
43            request.title,
44            request.description,
45            request.scheduled_date,
46            request.location,
47        )?;
48
49        let created = self.repository.create(&meeting).await?;
50        Ok(MeetingResponse::from(created))
51    }
52
53    pub async fn get_meeting(&self, id: Uuid) -> Result<Option<MeetingResponse>, String> {
54        let meeting = self.repository.find_by_id(id).await?;
55        Ok(meeting.map(MeetingResponse::from))
56    }
57
58    pub async fn list_meetings_by_building(
59        &self,
60        building_id: Uuid,
61    ) -> Result<Vec<MeetingResponse>, String> {
62        let meetings = self.repository.find_by_building(building_id).await?;
63        Ok(meetings.into_iter().map(MeetingResponse::from).collect())
64    }
65
66    pub async fn list_meetings_paginated(
67        &self,
68        page_request: &PageRequest,
69        organization_id: Option<Uuid>,
70    ) -> Result<(Vec<MeetingResponse>, i64), String> {
71        let (meetings, total) = self
72            .repository
73            .find_all_paginated(page_request, organization_id)
74            .await?;
75
76        let dtos = meetings.into_iter().map(MeetingResponse::from).collect();
77        Ok((dtos, total))
78    }
79
80    pub async fn update_meeting(
81        &self,
82        id: Uuid,
83        request: UpdateMeetingRequest,
84    ) -> Result<MeetingResponse, String> {
85        let mut meeting = self
86            .repository
87            .find_by_id(id)
88            .await?
89            .ok_or_else(|| "Meeting not found".to_string())?;
90
91        // Update fields if provided
92        if let Some(title) = request.title {
93            if title.is_empty() {
94                return Err("Title cannot be empty".to_string());
95            }
96            meeting.title = title;
97        }
98
99        if let Some(description) = request.description {
100            meeting.description = Some(description);
101        }
102
103        if let Some(scheduled_date) = request.scheduled_date {
104            meeting.scheduled_date = scheduled_date;
105        }
106
107        if let Some(location) = request.location {
108            if location.is_empty() {
109                return Err("Location cannot be empty".to_string());
110            }
111            meeting.location = location;
112        }
113
114        meeting.updated_at = chrono::Utc::now();
115
116        let updated = self.repository.update(&meeting).await?;
117        Ok(MeetingResponse::from(updated))
118    }
119
120    pub async fn add_agenda_item(
121        &self,
122        id: Uuid,
123        request: AddAgendaItemRequest,
124    ) -> Result<MeetingResponse, String> {
125        let mut meeting = self
126            .repository
127            .find_by_id(id)
128            .await?
129            .ok_or_else(|| "Meeting not found".to_string())?;
130
131        meeting.add_agenda_item(request.item)?;
132
133        let updated = self.repository.update(&meeting).await?;
134        Ok(MeetingResponse::from(updated))
135    }
136
137    pub async fn complete_meeting(
138        &self,
139        id: Uuid,
140        request: CompleteMeetingRequest,
141    ) -> Result<MeetingResponse, String> {
142        let mut meeting = self
143            .repository
144            .find_by_id(id)
145            .await?
146            .ok_or_else(|| "Meeting not found".to_string())?;
147
148        meeting.complete(request.attendees_count)?;
149
150        let updated = self.repository.update(&meeting).await?;
151        Ok(MeetingResponse::from(updated))
152    }
153
154    pub async fn cancel_meeting(&self, id: Uuid) -> Result<MeetingResponse, String> {
155        let mut meeting = self
156            .repository
157            .find_by_id(id)
158            .await?
159            .ok_or_else(|| "Meeting not found".to_string())?;
160
161        meeting.cancel()?;
162
163        let updated = self.repository.update(&meeting).await?;
164        Ok(MeetingResponse::from(updated))
165    }
166
167    pub async fn reschedule_meeting(
168        &self,
169        id: Uuid,
170        new_date: chrono::DateTime<chrono::Utc>,
171    ) -> Result<MeetingResponse, String> {
172        let mut meeting = self
173            .repository
174            .find_by_id(id)
175            .await?
176            .ok_or_else(|| "Meeting not found".to_string())?;
177
178        meeting.reschedule(new_date)?;
179
180        let updated = self.repository.update(&meeting).await?;
181        Ok(MeetingResponse::from(updated))
182    }
183
184    pub async fn delete_meeting(&self, id: Uuid) -> Result<bool, String> {
185        self.repository.delete(id).await
186    }
187
188    /// Attach minutes document to a completed meeting (Issue #313)
189    pub async fn attach_minutes(
190        &self,
191        id: Uuid,
192        document_id: Uuid,
193    ) -> Result<MeetingResponse, String> {
194        let mut meeting = self
195            .repository
196            .find_by_id(id)
197            .await?
198            .ok_or_else(|| "Meeting not found".to_string())?;
199
200        meeting.set_minutes_sent(document_id)?;
201
202        let updated = self.repository.update(&meeting).await?;
203        Ok(MeetingResponse::from(updated))
204    }
205
206    /// Valide le quorum d'une AG (Art. 3.87 §5 CC).
207    /// Doit être appelé AVANT que les votes soient ouverts.
208    /// Si quorum non atteint, déclenche automatiquement la création d'une 2e convocation
209    /// pour le même bâtiment (si ConvocationUseCases disponible).
210    /// Retourne Ok(true) si quorum atteint, Ok(false) si 2e convocation requise.
211    pub async fn validate_quorum(
212        &self,
213        meeting_id: Uuid,
214        present_quotas: f64,
215        total_quotas: f64,
216    ) -> Result<(bool, MeetingResponse), String> {
217        let mut meeting = self
218            .repository
219            .find_by_id(meeting_id)
220            .await?
221            .ok_or_else(|| "Meeting not found".to_string())?;
222
223        let quorum_reached = meeting.validate_quorum(present_quotas, total_quotas)?;
224        let updated = self.repository.update(&meeting).await?;
225
226        // Art. 3.87 §5 CC: Si quorum non atteint, déclencher une 2e convocation
227        if !quorum_reached {
228            if let Some(convocation_uc) = &self.convocation_use_cases {
229                // Create second meeting (15 days after first)
230                let second_meeting_date = meeting.scheduled_date + Duration::days(15);
231                let second_meeting_id = Uuid::new_v4();
232
233                // Schedule second convocation (language defaults to FR)
234                let _result = convocation_uc
235                    .schedule_second_convocation(
236                        meeting.organization_id,
237                        meeting.building_id,
238                        meeting_id,
239                        second_meeting_id,
240                        second_meeting_date,
241                        "FR".to_string(),
242                        Uuid::nil(), // system-created convocation
243                    )
244                    .await;
245                // Note: We don't fail if second convocation scheduling fails
246                // (could log the error, but don't block the quorum validation result)
247            }
248        }
249
250        Ok((quorum_reached, MeetingResponse::from(updated)))
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::application::dto::PageRequest;
258    use crate::application::ports::MeetingRepository;
259    use crate::domain::entities::{MeetingStatus, MeetingType};
260    use async_trait::async_trait;
261    use chrono::{Duration, Utc};
262    use mockall::mock;
263    use std::sync::Arc;
264
265    mock! {
266        MeetingRepo {}
267
268        #[async_trait]
269        impl MeetingRepository for MeetingRepo {
270            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
271            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
272            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
273            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
274            async fn delete(&self, id: Uuid) -> Result<bool, String>;
275            async fn find_all_paginated(
276                &self,
277                page_request: &PageRequest,
278                organization_id: Option<Uuid>,
279            ) -> Result<(Vec<Meeting>, i64), String>;
280        }
281    }
282
283    /// Helper to build a valid Meeting for testing purposes.
284    fn make_meeting(building_id: Uuid, org_id: Uuid) -> Meeting {
285        Meeting::new(
286            org_id,
287            building_id,
288            MeetingType::Ordinary,
289            "AGO 2024".to_string(),
290            Some("Annual general assembly".to_string()),
291            Utc::now() + Duration::days(30),
292            "Salle des fêtes".to_string(),
293        )
294        .unwrap()
295    }
296
297    // ---------------------------------------------------------------
298    // 1. Create meeting success
299    // ---------------------------------------------------------------
300    #[tokio::test]
301    async fn test_create_meeting_success() {
302        let mut mock_repo = MockMeetingRepo::new();
303
304        mock_repo.expect_create().returning(|m| Ok(m.clone()));
305
306        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
307
308        let request = CreateMeetingRequest {
309            organization_id: Uuid::new_v4(),
310            building_id: Uuid::new_v4(),
311            meeting_type: MeetingType::Ordinary,
312            title: "AGO 2024".to_string(),
313            description: Some("Annual assembly".to_string()),
314            scheduled_date: Utc::now() + Duration::days(30),
315            location: "Salle communale".to_string(),
316        };
317
318        let result = use_cases.create_meeting(request).await;
319        assert!(result.is_ok());
320        let response = result.unwrap();
321        assert_eq!(response.title, "AGO 2024");
322        assert_eq!(response.status, MeetingStatus::Scheduled);
323    }
324
325    // ---------------------------------------------------------------
326    // 2. Create meeting with invalid data (empty title)
327    // ---------------------------------------------------------------
328    #[tokio::test]
329    async fn test_create_meeting_empty_title_fails() {
330        let mock_repo = MockMeetingRepo::new();
331        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
332
333        let request = CreateMeetingRequest {
334            organization_id: Uuid::new_v4(),
335            building_id: Uuid::new_v4(),
336            meeting_type: MeetingType::Ordinary,
337            title: "".to_string(),
338            description: None,
339            scheduled_date: Utc::now() + Duration::days(30),
340            location: "Salle communale".to_string(),
341        };
342
343        let result = use_cases.create_meeting(request).await;
344        assert!(result.is_err());
345        assert!(result.unwrap_err().contains("Title cannot be empty"));
346    }
347
348    // ---------------------------------------------------------------
349    // 3. Create meeting with empty location
350    // ---------------------------------------------------------------
351    #[tokio::test]
352    async fn test_create_meeting_empty_location_fails() {
353        let mock_repo = MockMeetingRepo::new();
354        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
355
356        let request = CreateMeetingRequest {
357            organization_id: Uuid::new_v4(),
358            building_id: Uuid::new_v4(),
359            meeting_type: MeetingType::Extraordinary,
360            title: "AGE 2024".to_string(),
361            description: None,
362            scheduled_date: Utc::now() + Duration::days(15),
363            location: "".to_string(),
364        };
365
366        let result = use_cases.create_meeting(request).await;
367        assert!(result.is_err());
368        assert!(result.unwrap_err().contains("Location cannot be empty"));
369    }
370
371    // ---------------------------------------------------------------
372    // 4. Get meeting by ID — found
373    // ---------------------------------------------------------------
374    #[tokio::test]
375    async fn test_get_meeting_found() {
376        let building_id = Uuid::new_v4();
377        let org_id = Uuid::new_v4();
378        let meeting = make_meeting(building_id, org_id);
379        let meeting_id = meeting.id;
380
381        let mut mock_repo = MockMeetingRepo::new();
382        mock_repo
383            .expect_find_by_id()
384            .withf(move |id| *id == meeting_id)
385            .returning(move |_| Ok(Some(make_meeting(building_id, org_id))));
386
387        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
388
389        let result = use_cases.get_meeting(meeting_id).await;
390        assert!(result.is_ok());
391        assert!(result.unwrap().is_some());
392    }
393
394    // ---------------------------------------------------------------
395    // 5. Get meeting by ID — not found
396    // ---------------------------------------------------------------
397    #[tokio::test]
398    async fn test_get_meeting_not_found() {
399        let mut mock_repo = MockMeetingRepo::new();
400        mock_repo.expect_find_by_id().returning(|_| Ok(None));
401
402        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
403
404        let result = use_cases.get_meeting(Uuid::new_v4()).await;
405        assert!(result.is_ok());
406        assert!(result.unwrap().is_none());
407    }
408
409    // ---------------------------------------------------------------
410    // 6. List meetings by building
411    // ---------------------------------------------------------------
412    #[tokio::test]
413    async fn test_list_meetings_by_building() {
414        let building_id = Uuid::new_v4();
415        let org_id = Uuid::new_v4();
416
417        let mut mock_repo = MockMeetingRepo::new();
418        mock_repo
419            .expect_find_by_building()
420            .withf(move |id| *id == building_id)
421            .returning(move |_| {
422                Ok(vec![
423                    make_meeting(building_id, org_id),
424                    make_meeting(building_id, org_id),
425                ])
426            });
427
428        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
429
430        let result = use_cases.list_meetings_by_building(building_id).await;
431        assert!(result.is_ok());
432        assert_eq!(result.unwrap().len(), 2);
433    }
434
435    // ---------------------------------------------------------------
436    // 7. Update meeting — success
437    // ---------------------------------------------------------------
438    #[tokio::test]
439    async fn test_update_meeting_success() {
440        let building_id = Uuid::new_v4();
441        let org_id = Uuid::new_v4();
442        let meeting = make_meeting(building_id, org_id);
443        let meeting_id = meeting.id;
444        let meeting_clone = meeting.clone();
445
446        let mut mock_repo = MockMeetingRepo::new();
447        mock_repo
448            .expect_find_by_id()
449            .withf(move |id| *id == meeting_id)
450            .returning(move |_| Ok(Some(meeting_clone.clone())));
451
452        mock_repo.expect_update().returning(|m| Ok(m.clone()));
453
454        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
455
456        let request = UpdateMeetingRequest {
457            title: Some("Renamed AGO".to_string()),
458            description: Some("Updated description".to_string()),
459            scheduled_date: None,
460            location: None,
461        };
462
463        let result = use_cases.update_meeting(meeting_id, request).await;
464        assert!(result.is_ok());
465        let response = result.unwrap();
466        assert_eq!(response.title, "Renamed AGO");
467    }
468
469    // ---------------------------------------------------------------
470    // 8. Update meeting — not found
471    // ---------------------------------------------------------------
472    #[tokio::test]
473    async fn test_update_meeting_not_found() {
474        let mut mock_repo = MockMeetingRepo::new();
475        mock_repo.expect_find_by_id().returning(|_| Ok(None));
476
477        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
478
479        let request = UpdateMeetingRequest {
480            title: Some("New title".to_string()),
481            description: None,
482            scheduled_date: None,
483            location: None,
484        };
485
486        let result = use_cases.update_meeting(Uuid::new_v4(), request).await;
487        assert!(result.is_err());
488        assert!(result.unwrap_err().contains("Meeting not found"));
489    }
490
491    // ---------------------------------------------------------------
492    // 9. Update meeting — empty title rejected
493    // ---------------------------------------------------------------
494    #[tokio::test]
495    async fn test_update_meeting_empty_title_rejected() {
496        let building_id = Uuid::new_v4();
497        let org_id = Uuid::new_v4();
498        let meeting = make_meeting(building_id, org_id);
499        let meeting_id = meeting.id;
500        let meeting_clone = meeting.clone();
501
502        let mut mock_repo = MockMeetingRepo::new();
503        mock_repo
504            .expect_find_by_id()
505            .returning(move |_| Ok(Some(meeting_clone.clone())));
506
507        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
508
509        let request = UpdateMeetingRequest {
510            title: Some("".to_string()),
511            description: None,
512            scheduled_date: None,
513            location: None,
514        };
515
516        let result = use_cases.update_meeting(meeting_id, request).await;
517        assert!(result.is_err());
518        assert!(result.unwrap_err().contains("Title cannot be empty"));
519    }
520
521    // ---------------------------------------------------------------
522    // 10. Delete meeting
523    // ---------------------------------------------------------------
524    #[tokio::test]
525    async fn test_delete_meeting_success() {
526        let meeting_id = Uuid::new_v4();
527
528        let mut mock_repo = MockMeetingRepo::new();
529        mock_repo
530            .expect_delete()
531            .withf(move |id| *id == meeting_id)
532            .returning(|_| Ok(true));
533
534        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
535
536        let result = use_cases.delete_meeting(meeting_id).await;
537        assert!(result.is_ok());
538        assert!(result.unwrap());
539    }
540
541    // ---------------------------------------------------------------
542    // 11. Validate quorum — reached (>50%)
543    // ---------------------------------------------------------------
544    #[tokio::test]
545    async fn test_validate_quorum_reached() {
546        let building_id = Uuid::new_v4();
547        let org_id = Uuid::new_v4();
548        let meeting = make_meeting(building_id, org_id);
549        let meeting_id = meeting.id;
550        let meeting_clone = meeting.clone();
551
552        let mut mock_repo = MockMeetingRepo::new();
553        mock_repo
554            .expect_find_by_id()
555            .withf(move |id| *id == meeting_id)
556            .returning(move |_| Ok(Some(meeting_clone.clone())));
557
558        mock_repo.expect_update().returning(|m| Ok(m.clone()));
559
560        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
561
562        // 600/1000 = 60% → quorum reached
563        let result = use_cases.validate_quorum(meeting_id, 600.0, 1000.0).await;
564        assert!(result.is_ok());
565        let (reached, response) = result.unwrap();
566        assert!(reached);
567        assert!(response.quorum_validated);
568        assert!((response.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
569    }
570
571    // ---------------------------------------------------------------
572    // 12. Validate quorum — not reached (<=50%)
573    // ---------------------------------------------------------------
574    #[tokio::test]
575    async fn test_validate_quorum_not_reached() {
576        let building_id = Uuid::new_v4();
577        let org_id = Uuid::new_v4();
578        let meeting = make_meeting(building_id, org_id);
579        let meeting_id = meeting.id;
580        let meeting_clone = meeting.clone();
581
582        let mut mock_repo = MockMeetingRepo::new();
583        mock_repo
584            .expect_find_by_id()
585            .withf(move |id| *id == meeting_id)
586            .returning(move |_| Ok(Some(meeting_clone.clone())));
587
588        mock_repo.expect_update().returning(|m| Ok(m.clone()));
589
590        // No convocation_use_cases set, so second convocation won't be triggered
591        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
592
593        // 400/1000 = 40% → quorum NOT reached
594        let result = use_cases.validate_quorum(meeting_id, 400.0, 1000.0).await;
595        assert!(result.is_ok());
596        let (reached, response) = result.unwrap();
597        assert!(!reached);
598        assert!(!response.quorum_validated);
599        assert!((response.quorum_percentage.unwrap() - 40.0).abs() < 0.01);
600    }
601
602    // ---------------------------------------------------------------
603    // 13. Validate quorum — meeting not found
604    // ---------------------------------------------------------------
605    #[tokio::test]
606    async fn test_validate_quorum_meeting_not_found() {
607        let mut mock_repo = MockMeetingRepo::new();
608        mock_repo.expect_find_by_id().returning(|_| Ok(None));
609
610        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
611
612        let result = use_cases
613            .validate_quorum(Uuid::new_v4(), 600.0, 1000.0)
614            .await;
615        assert!(result.is_err());
616        assert!(result.unwrap_err().contains("Meeting not found"));
617    }
618
619    // ---------------------------------------------------------------
620    // 14. Complete meeting via use case
621    // ---------------------------------------------------------------
622    #[tokio::test]
623    async fn test_complete_meeting_success() {
624        let building_id = Uuid::new_v4();
625        let org_id = Uuid::new_v4();
626        let meeting = make_meeting(building_id, org_id);
627        let meeting_id = meeting.id;
628        let meeting_clone = meeting.clone();
629
630        let mut mock_repo = MockMeetingRepo::new();
631        mock_repo
632            .expect_find_by_id()
633            .withf(move |id| *id == meeting_id)
634            .returning(move |_| Ok(Some(meeting_clone.clone())));
635
636        mock_repo.expect_update().returning(|m| Ok(m.clone()));
637
638        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
639
640        let request = CompleteMeetingRequest {
641            attendees_count: 42,
642        };
643        let result = use_cases.complete_meeting(meeting_id, request).await;
644        assert!(result.is_ok());
645        let response = result.unwrap();
646        assert_eq!(response.status, MeetingStatus::Completed);
647        assert_eq!(response.attendees_count, Some(42));
648    }
649
650    // ---------------------------------------------------------------
651    // 15. Cancel meeting via use case
652    // ---------------------------------------------------------------
653    #[tokio::test]
654    async fn test_cancel_meeting_success() {
655        let building_id = Uuid::new_v4();
656        let org_id = Uuid::new_v4();
657        let meeting = make_meeting(building_id, org_id);
658        let meeting_id = meeting.id;
659        let meeting_clone = meeting.clone();
660
661        let mut mock_repo = MockMeetingRepo::new();
662        mock_repo
663            .expect_find_by_id()
664            .withf(move |id| *id == meeting_id)
665            .returning(move |_| Ok(Some(meeting_clone.clone())));
666
667        mock_repo.expect_update().returning(|m| Ok(m.clone()));
668
669        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
670
671        let result = use_cases.cancel_meeting(meeting_id).await;
672        assert!(result.is_ok());
673        assert_eq!(result.unwrap().status, MeetingStatus::Cancelled);
674    }
675
676    // ---------------------------------------------------------------
677    // 16. Add agenda item via use case
678    // ---------------------------------------------------------------
679    #[tokio::test]
680    async fn test_add_agenda_item_success() {
681        let building_id = Uuid::new_v4();
682        let org_id = Uuid::new_v4();
683        let meeting = make_meeting(building_id, org_id);
684        let meeting_id = meeting.id;
685        let meeting_clone = meeting.clone();
686
687        let mut mock_repo = MockMeetingRepo::new();
688        mock_repo
689            .expect_find_by_id()
690            .withf(move |id| *id == meeting_id)
691            .returning(move |_| Ok(Some(meeting_clone.clone())));
692
693        mock_repo.expect_update().returning(|m| Ok(m.clone()));
694
695        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
696
697        let request = AddAgendaItemRequest {
698            item: "Approbation des comptes".to_string(),
699        };
700        let result = use_cases.add_agenda_item(meeting_id, request).await;
701        assert!(result.is_ok());
702        let response = result.unwrap();
703        assert_eq!(response.agenda.len(), 1);
704        assert_eq!(response.agenda[0], "Approbation des comptes");
705    }
706
707    // ---------------------------------------------------------------
708    // 17. Validate quorum — exact 50% NOT reached (Art. 3.87 §5: >50% strict)
709    // ---------------------------------------------------------------
710    #[tokio::test]
711    async fn test_validate_quorum_exact_50_percent_not_reached() {
712        let building_id = Uuid::new_v4();
713        let org_id = Uuid::new_v4();
714        let meeting = make_meeting(building_id, org_id);
715        let meeting_id = meeting.id;
716        let meeting_clone = meeting.clone();
717
718        let mut mock_repo = MockMeetingRepo::new();
719        mock_repo
720            .expect_find_by_id()
721            .withf(move |id| *id == meeting_id)
722            .returning(move |_| Ok(Some(meeting_clone.clone())));
723
724        mock_repo.expect_update().returning(|m| Ok(m.clone()));
725
726        let use_cases = MeetingUseCases::new(Arc::new(mock_repo));
727
728        // 500/1000 = exactly 50% → quorum NOT reached (Art. 3.87 §5: strictly >50%)
729        let result = use_cases.validate_quorum(meeting_id, 500.0, 1000.0).await;
730        assert!(result.is_ok());
731        let (reached, response) = result.unwrap();
732        assert!(!reached);
733        assert!(!response.quorum_validated);
734        assert!((response.quorum_percentage.unwrap() - 50.0).abs() < 0.01);
735    }
736}