Skip to main content

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::{MeetingCompletionCheckerPort, MeetingRepository};
6use crate::domain::entities::Meeting;
7use chrono::Duration;
8use rust_decimal::Decimal;
9#[cfg(test)]
10use rust_decimal_macros::dec;
11use std::sync::Arc;
12use uuid::Uuid;
13
14pub struct MeetingUseCases {
15    repository: Arc<dyn MeetingRepository>,
16    /// Résolution de l'ACP dont c'est l'assemblée.
17    ///
18    /// Optionnel pour ne pas casser les constructeurs des tests, mais son
19    /// absence fait échouer la création : une assemblée sans ACP n'est
20    /// l'assemblée de personne (ADR-0045).
21    building_repository: Option<Arc<dyn crate::application::ports::BuildingRepository>>,
22    convocation_use_cases: Option<Arc<crate::application::use_cases::ConvocationUseCases>>,
23    /// Track H Story H3 — Port DB pour construire la checklist Art. 3.87
24    /// §3-5 CC avant `complete_meeting`. `None` signifie : pas de gate
25    /// activée (tests legacy / fallback compat backward).
26    completion_checker: Option<Arc<dyn MeetingCompletionCheckerPort>>,
27}
28
29impl MeetingUseCases {
30    pub fn new(repository: Arc<dyn MeetingRepository>) -> Self {
31        Self {
32            repository,
33            building_repository: None,
34            convocation_use_cases: None,
35            completion_checker: None,
36        }
37    }
38
39    /// Create MeetingUseCases with ConvocationUseCases for automatic 2nd convocation scheduling
40    pub fn new_with_convocation(
41        repository: Arc<dyn MeetingRepository>,
42        convocation_use_cases: Arc<crate::application::use_cases::ConvocationUseCases>,
43    ) -> Self {
44        Self {
45            repository,
46            building_repository: None,
47            convocation_use_cases: Some(convocation_use_cases),
48            completion_checker: None,
49        }
50    }
51
52    /// Track H Story H3 — Configure le gate `assert_can_complete()` sur
53    /// `complete_meeting()`. Builder pattern fluide pour ne pas multiplier
54    /// les constructeurs et préserver la compat backward des tests existants.
55    pub fn with_completion_checker(
56        mut self,
57        checker: Arc<dyn MeetingCompletionCheckerPort>,
58    ) -> Self {
59        self.completion_checker = Some(checker);
60        self
61    }
62
63    /// Track H Story H3 — Construit la checklist Art. 3.87 §3-5 CC et liste
64    /// les invariants manquants pour le FE (composant
65    /// `<MissingInvariantsList>` + désactivation bouton « Clôturer »).
66    ///
67    /// Retourne `(checklist, missing[])` où `missing[]` est déjà sérialisé
68    /// en `serde_json::Value` au format attendu par le frontend (i18n keys
69    /// = `meeting.missing.<type>`).
70    ///
71    /// Erreurs :
72    /// - `"Meeting not found"` → 404.
73    /// - `"Completion checker not configured"` → 404 (état non câblé).
74    pub async fn build_completion_checklist(
75        &self,
76        meeting_id: Uuid,
77    ) -> Result<
78        (
79            crate::domain::entities::MeetingCompletionChecklist,
80            Vec<serde_json::Value>,
81        ),
82        String,
83    > {
84        let meeting = self
85            .repository
86            .find_by_id(meeting_id)
87            .await?
88            .ok_or_else(|| "Meeting not found".to_string())?;
89
90        let checker = self
91            .completion_checker
92            .as_ref()
93            .ok_or_else(|| "Completion checker not configured".to_string())?;
94
95        let checklist = checker.build_checklist(meeting_id).await?;
96
97        // Calcule les invariants manquants côté domain (pas d'I/O).
98        let missing_invariants = match meeting.assert_can_complete(&checklist) {
99            Ok(()) => Vec::new(),
100            Err(err) => err.missing,
101        };
102
103        // Sérialisation manuelle Decimal-as-string (mémoire `no-f64-in-money`).
104        use crate::domain::entities::MissingInvariant;
105        let missing_json: Vec<serde_json::Value> = missing_invariants
106            .iter()
107            .map(|m| match m {
108                MissingInvariant::ConvocationsNotSent => {
109                    serde_json::json!({"type": "ConvocationsNotSent"})
110                }
111                MissingInvariant::VotesNotClosed { open_resolutions } => serde_json::json!({
112                    "type": "VotesNotClosed",
113                    "open_resolutions": open_resolutions,
114                }),
115                MissingInvariant::AttendanceNotRecorded => {
116                    serde_json::json!({"type": "AttendanceNotRecorded"})
117                }
118                MissingInvariant::QuorumNotReached {
119                    attended_quotas,
120                    total_quotas,
121                } => serde_json::json!({
122                    "type": "QuorumNotReached",
123                    "attended_quotas": attended_quotas.to_string(),
124                    "total_quotas": total_quotas.to_string(),
125                }),
126                // Story H9 — volet têtes du quorum double (Art. 3.87 §5).
127                MissingInvariant::HeadCountQuorumNotReached {
128                    present_owners_count,
129                    total_owners_count,
130                } => serde_json::json!({
131                    "type": "HeadCountQuorumNotReached",
132                    "present_owners_count": present_owners_count,
133                    "total_owners_count": total_owners_count,
134                }),
135                MissingInvariant::MinutesDraftMissing => {
136                    serde_json::json!({"type": "MinutesDraftMissing"})
137                }
138            })
139            .collect();
140
141        Ok((checklist, missing_json))
142    }
143
144    /// Câble la résolution de l'ACP depuis l'immeuble.
145    pub fn with_acp_resolution(
146        mut self,
147        building_repository: Arc<dyn crate::application::ports::BuildingRepository>,
148    ) -> Self {
149        self.building_repository = Some(building_repository);
150        self
151    }
152
153    /// L'ACP dont c'est l'assemblée, résolue depuis l'immeuble.
154    ///
155    /// Art. 3.87 § 1er : l'assemblée générale est l'organe de l'association.
156    /// Le syndic la tient (§ 2), il ne la possède pas.
157    async fn resoudre_lacp(&self, building_id: Uuid) -> Result<Uuid, String> {
158        let Some(building_repo) = &self.building_repository else {
159            return Err(
160                "Impossible de déterminer l'ACP dont c'est l'assemblée : dépôt d'immeubles \
161                 non câblé"
162                    .to_string(),
163            );
164        };
165        let building = building_repo
166            .find_by_id(building_id)
167            .await?
168            .ok_or_else(|| "Immeuble introuvable".to_string())?;
169        Ok(building.acp_id)
170    }
171
172    pub async fn create_meeting(
173        &self,
174        request: CreateMeetingRequest,
175    ) -> Result<MeetingResponse, String> {
176        let acp_id = self.resoudre_lacp(request.building_id).await?;
177        let mut meeting = Meeting::new(
178            acp_id,
179            request.organization_id,
180            request.building_id,
181            request.meeting_type,
182            request.title,
183            request.description,
184            request.scheduled_date,
185            request.location,
186        )?;
187
188        // Art. 3.87 §5 CC: 2nd convocation skips quorum requirement
189        if request.is_second_convocation {
190            meeting.is_second_convocation = true;
191        }
192
193        let created = self.repository.create(&meeting).await?;
194        Ok(MeetingResponse::from(created))
195    }
196
197    pub async fn get_meeting(&self, id: Uuid) -> Result<Option<MeetingResponse>, String> {
198        let meeting = self.repository.find_by_id(id).await?;
199        Ok(meeting.map(MeetingResponse::from))
200    }
201
202    pub async fn list_meetings_by_building(
203        &self,
204        building_id: Uuid,
205    ) -> Result<Vec<MeetingResponse>, String> {
206        let meetings = self.repository.find_by_building(building_id).await?;
207        Ok(meetings.into_iter().map(MeetingResponse::from).collect())
208    }
209
210    pub async fn list_meetings_paginated(
211        &self,
212        page_request: &PageRequest,
213        organization_id: Option<Uuid>,
214    ) -> Result<(Vec<MeetingResponse>, i64), String> {
215        let (meetings, total) = self
216            .repository
217            .find_all_paginated(page_request, organization_id)
218            .await?;
219
220        let dtos = meetings.into_iter().map(MeetingResponse::from).collect();
221        Ok((dtos, total))
222    }
223
224    pub async fn update_meeting(
225        &self,
226        id: Uuid,
227        request: UpdateMeetingRequest,
228    ) -> Result<MeetingResponse, String> {
229        let mut meeting = self
230            .repository
231            .find_by_id(id)
232            .await?
233            .ok_or_else(|| "Meeting not found".to_string())?;
234
235        // Update fields if provided
236        if let Some(title) = request.title {
237            if title.is_empty() {
238                return Err("Title cannot be empty".to_string());
239            }
240            meeting.title = title;
241        }
242
243        if let Some(description) = request.description {
244            meeting.description = Some(description);
245        }
246
247        if let Some(scheduled_date) = request.scheduled_date {
248            meeting.scheduled_date = scheduled_date;
249        }
250
251        if let Some(location) = request.location {
252            if location.is_empty() {
253                return Err("Location cannot be empty".to_string());
254            }
255            meeting.location = location;
256        }
257
258        meeting.updated_at = chrono::Utc::now();
259
260        let updated = self.repository.update(&meeting).await?;
261        Ok(MeetingResponse::from(updated))
262    }
263
264    pub async fn add_agenda_item(
265        &self,
266        id: Uuid,
267        request: AddAgendaItemRequest,
268    ) -> Result<MeetingResponse, String> {
269        let mut meeting = self
270            .repository
271            .find_by_id(id)
272            .await?
273            .ok_or_else(|| "Meeting not found".to_string())?;
274
275        meeting.add_agenda_item(request.item)?;
276
277        let updated = self.repository.update(&meeting).await?;
278        Ok(MeetingResponse::from(updated))
279    }
280
281    /// Track H Story H3 — Clôture une AG après vérification stricte des
282    /// invariants Art. 3.87 §3-5 CC :
283    ///   1. Charge le meeting (404 si absent).
284    ///   2. Charge la checklist via `completion_checker.build_checklist`.
285    ///   3. `meeting.assert_can_complete(&checklist)?` — propage l'erreur
286    ///      typée via `From<MeetingNotCompletableError> for String` (préfixe
287    ///      `MEETING_NOT_COMPLETABLE:` reconnu par le handler → 422).
288    ///   4. `meeting.complete_internal()?` — state machine.
289    ///   5. Persistance.
290    ///
291    /// **DEPRECATED param** : `request.attendees_count` est conservé pour
292    /// la compat backward de l'API ; la **source de vérité** depuis Story H3
293    /// est `checklist.attended_quotas` (DB agrégat present_quotas). Si le
294    /// checker n'est pas branché (`completion_checker = None`, cas legacy
295    /// tests / hors-prod), on retombe sur l'ancien comportement
296    /// `Meeting::complete(attendees_count)` pour ne pas casser la suite
297    /// existante.
298    pub async fn complete_meeting(
299        &self,
300        id: Uuid,
301        request: CompleteMeetingRequest,
302    ) -> Result<MeetingResponse, String> {
303        let mut meeting = self
304            .repository
305            .find_by_id(id)
306            .await?
307            .ok_or_else(|| "Meeting not found".to_string())?;
308
309        if let Some(checker) = &self.completion_checker {
310            // Gate Art. 3.87 §3-5 CC — branché en prod via `with_completion_checker`.
311            let checklist = checker.build_checklist(id).await?;
312            // `?` invoque `From<MeetingNotCompletableError> for String` qui
313            // produit le payload préfixé reconnaissable par le handler 422.
314            meeting.assert_can_complete(&checklist)?;
315            meeting.complete_internal()?;
316            // `attendees_count` reste exposé sur le DTO pour compat — on le
317            // dérive de la checklist (présents+représentés agrégés). Le param
318            // entrant n'est PAS utilisé (defense-in-depth contre tampering).
319            let _ = request.attendees_count; // explicitement ignoré
320                                             // Pour préserver le retour DTO existant, on stocke un proxy
321                                             // entier sur `attendees_count` quand il est dérivable. À défaut
322                                             // on garde None.
323                                             // NB : la perte de précision Decimal→i32 est acceptable ici car
324                                             // c'est juste pour rétro-compat affichage — la vraie présence
325                                             // vit dans `present_quotas` (Decimal, déjà sur l'entité Meeting).
326            use rust_decimal::prelude::ToPrimitive;
327            meeting.attendees_count = checklist.attended_quotas.to_i32();
328        } else {
329            // Path legacy (tests, environnements non câblés). Equivalent à
330            // l'ancien `Meeting::complete(n)` — pour permettre une migration
331            // progressive sans rupture de l'API publique.
332            #[allow(deprecated)]
333            meeting.complete(request.attendees_count)?;
334        }
335
336        let updated = self.repository.update(&meeting).await?;
337        Ok(MeetingResponse::from(updated))
338    }
339
340    pub async fn cancel_meeting(&self, id: Uuid) -> Result<MeetingResponse, String> {
341        let mut meeting = self
342            .repository
343            .find_by_id(id)
344            .await?
345            .ok_or_else(|| "Meeting not found".to_string())?;
346
347        meeting.cancel()?;
348
349        let updated = self.repository.update(&meeting).await?;
350        Ok(MeetingResponse::from(updated))
351    }
352
353    pub async fn reschedule_meeting(
354        &self,
355        id: Uuid,
356        new_date: chrono::DateTime<chrono::Utc>,
357    ) -> Result<MeetingResponse, String> {
358        let mut meeting = self
359            .repository
360            .find_by_id(id)
361            .await?
362            .ok_or_else(|| "Meeting not found".to_string())?;
363
364        meeting.reschedule(new_date)?;
365
366        let updated = self.repository.update(&meeting).await?;
367        Ok(MeetingResponse::from(updated))
368    }
369
370    pub async fn delete_meeting(&self, id: Uuid) -> Result<bool, String> {
371        self.repository.delete(id).await
372    }
373
374    /// Attach minutes document to a completed meeting (Issue #313)
375    pub async fn attach_minutes(
376        &self,
377        id: Uuid,
378        document_id: Uuid,
379    ) -> Result<MeetingResponse, String> {
380        let mut meeting = self
381            .repository
382            .find_by_id(id)
383            .await?
384            .ok_or_else(|| "Meeting not found".to_string())?;
385
386        meeting.set_minutes_sent(document_id)?;
387
388        let updated = self.repository.update(&meeting).await?;
389        Ok(MeetingResponse::from(updated))
390    }
391
392    /// Valide le quorum d'une AG (Art. 3.87 §5 CC).
393    /// Doit être appelé AVANT que les votes soient ouverts.
394    /// Si quorum non atteint, déclenche automatiquement la création d'une 2e convocation
395    /// pour le même bâtiment (si ConvocationUseCases disponible).
396    /// Retourne Ok(true) si quorum atteint, Ok(false) si 2e convocation requise.
397    pub async fn validate_quorum(
398        &self,
399        meeting_id: Uuid,
400        present_quotas: Decimal,
401        total_quotas: Decimal,
402    ) -> Result<(bool, MeetingResponse), String> {
403        let mut meeting = self
404            .repository
405            .find_by_id(meeting_id)
406            .await?
407            .ok_or_else(|| "Meeting not found".to_string())?;
408
409        let quorum_reached = meeting.validate_quorum(present_quotas, total_quotas)?;
410        let updated = self.repository.update(&meeting).await?;
411
412        // Art. 3.87 §5 CC: Si quorum non atteint, déclencher une 2e convocation
413        if !quorum_reached {
414            if let Some(convocation_uc) = &self.convocation_use_cases {
415                // Create second meeting (15 days after first)
416                let second_meeting_date = meeting.scheduled_date + Duration::days(15);
417                let second_meeting_id = Uuid::new_v4();
418
419                // Schedule second convocation (language defaults to FR)
420                let _result = convocation_uc
421                    .schedule_second_convocation(
422                        meeting.organization_id,
423                        meeting.building_id,
424                        meeting_id,
425                        second_meeting_id,
426                        second_meeting_date,
427                        "FR".to_string(),
428                        Uuid::nil(), // system-created convocation
429                    )
430                    .await;
431                // Note: We don't fail if second convocation scheduling fails
432                // (could log the error, but don't block the quorum validation result)
433            }
434        }
435
436        Ok((quorum_reached, MeetingResponse::from(updated)))
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::application::dto::PageRequest;
444    use crate::application::ports::MeetingRepository;
445    use crate::domain::entities::{MeetingStatus, MeetingType};
446    use async_trait::async_trait;
447    use chrono::{Duration, Utc};
448    use mockall::mock;
449    use std::sync::Arc;
450
451    use crate::domain::entities::Building;
452
453    /// Dépôt d'immeubles minimal : un immeuble rattaché à une ACP quelconque.
454    ///
455    /// L'assemblée cherche désormais l'ACP dont elle est l'assemblée
456    /// (Art. 3.87 § 1er, ADR-0045). Les tests qui vérifient le rattachement
457    /// nomment l'ACP ; les autres n'ont besoin que d'un immeuble qui existe.
458    fn mock_building_repo() -> Arc<dyn crate::application::ports::BuildingRepository> {
459        mock_building_repo_pour(Uuid::new_v4())
460    }
461
462    fn mock_building_repo_pour(
463        acp_id: Uuid,
464    ) -> Arc<dyn crate::application::ports::BuildingRepository> {
465        struct Depot {
466            acp_id: Uuid,
467        }
468        impl Depot {
469            fn immeuble(&self) -> Building {
470                Building::new(
471                    self.acp_id,
472                    "Résidence du Parc".to_string(),
473                    "12 Rue de la Loi".to_string(),
474                    "Brussels".to_string(),
475                    "1000".to_string(),
476                    "Belgium".to_string(),
477                    10,
478                    1000,
479                    Some(2015),
480                )
481                .expect("immeuble valide")
482            }
483        }
484        #[async_trait]
485        impl crate::application::ports::BuildingRepository for Depot {
486            async fn create(&self, b: &Building) -> Result<Building, String> {
487                Ok(b.clone())
488            }
489            async fn find_by_id(&self, _id: Uuid) -> Result<Option<Building>, String> {
490                Ok(Some(self.immeuble()))
491            }
492            async fn find_all(&self) -> Result<Vec<Building>, String> {
493                Ok(vec![self.immeuble()])
494            }
495            async fn find_all_paginated(
496                &self,
497                _p: &PageRequest,
498                _f: &crate::application::dto::BuildingFilters,
499            ) -> Result<(Vec<Building>, i64), String> {
500                Ok((vec![self.immeuble()], 1))
501            }
502            async fn update(&self, b: &Building) -> Result<Building, String> {
503                Ok(b.clone())
504            }
505            async fn delete(&self, _id: Uuid) -> Result<bool, String> {
506                Ok(true)
507            }
508            async fn find_by_slug(&self, _s: &str) -> Result<Option<Building>, String> {
509                Ok(Some(self.immeuble()))
510            }
511            async fn find_by_id_with_metrics(
512                &self,
513                _id: Uuid,
514            ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>
515            {
516                Ok(None)
517            }
518        }
519        Arc::new(Depot { acp_id })
520    }
521
522    mock! {
523        MeetingRepo {}
524
525        #[async_trait]
526        impl MeetingRepository for MeetingRepo {
527            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
528            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
529            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
530            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
531            async fn delete(&self, id: Uuid) -> Result<bool, String>;
532            async fn find_all_paginated(
533                &self,
534                page_request: &PageRequest,
535                organization_id: Option<Uuid>,
536            ) -> Result<(Vec<Meeting>, i64), String>;
537        }
538    }
539
540    /// Helper to build a valid Meeting for testing purposes.
541    fn make_meeting(building_id: Uuid, org_id: Uuid) -> Meeting {
542        Meeting::new(
543            Uuid::new_v4(), // acp_id
544            org_id,
545            building_id,
546            MeetingType::Ordinary,
547            "AGO 2024".to_string(),
548            Some("Annual general assembly".to_string()),
549            Utc::now() + Duration::days(30),
550            "Salle des fêtes".to_string(),
551        )
552        .unwrap()
553    }
554
555    // ---------------------------------------------------------------
556    // 1. Create meeting success
557    // ---------------------------------------------------------------
558    /// Art. 3.87 § 1er et ADR-0045 : l'assemblée est l'organe de l'ACP.
559    ///
560    /// L'ACP se lit sur l'immeuble, jamais sur l'appelant. Un changement de
561    /// syndic ne rend pas les assemblées passées invisibles.
562    #[tokio::test]
563    async fn test_lassemblee_est_celle_de_lacp_pas_du_syndic() {
564        let acp_de_limmeuble = Uuid::new_v4();
565        let cabinet_qui_la_tient = Uuid::new_v4();
566
567        let mut mock_repo = MockMeetingRepo::new();
568        mock_repo.expect_create().returning(|m| Ok(m.clone()));
569
570        let use_cases = MeetingUseCases::new(Arc::new(mock_repo))
571            .with_acp_resolution(mock_building_repo_pour(acp_de_limmeuble));
572
573        let assemblee = use_cases
574            .create_meeting(CreateMeetingRequest {
575                organization_id: cabinet_qui_la_tient,
576                building_id: Uuid::new_v4(),
577                meeting_type: MeetingType::Ordinary,
578                title: "AGO 2026".to_string(),
579                description: None,
580                scheduled_date: Utc::now() + Duration::days(30),
581                location: "Salle communale".to_string(),
582                is_second_convocation: false,
583            })
584            .await
585            .expect("création valide");
586
587        assert_eq!(
588            assemblee.acp_id, acp_de_limmeuble,
589            "l'assemblée est celle de l'ACP de l'immeuble"
590        );
591        // La réponse n'expose pas le syndic, et c'est voulu : ce qui compte
592        // pour un client est de quelle copropriété relève l'assemblée. La
593        // trace d'auteur reste en base, elle n'est pas un droit d'accès.
594        assert_ne!(
595            assemblee.acp_id, cabinet_qui_la_tient,
596            "l'ACP et le syndic sont deux entités distinctes"
597        );
598    }
599
600    /// Sans dépôt d'immeubles, on ne sait pas de quelle ACP c'est l'assemblée.
601    #[tokio::test]
602    async fn test_pas_dassemblee_sans_acp_identifiable() {
603        let mut mock_repo = MockMeetingRepo::new();
604        mock_repo.expect_create().returning(|m| Ok(m.clone()));
605
606        let use_cases = MeetingUseCases::new(Arc::new(mock_repo)); // pas de résolution
607
608        let erreur = use_cases
609            .create_meeting(CreateMeetingRequest {
610                organization_id: Uuid::new_v4(),
611                building_id: Uuid::new_v4(),
612                meeting_type: MeetingType::Ordinary,
613                title: "AGO 2026".to_string(),
614                description: None,
615                scheduled_date: Utc::now() + Duration::days(30),
616                location: "Salle communale".to_string(),
617                is_second_convocation: false,
618            })
619            .await
620            .expect_err("doit refuser");
621
622        assert!(
623            erreur.contains("ACP"),
624            "le refus doit nommer ce qui manque : {erreur}"
625        );
626    }
627
628    #[tokio::test]
629    async fn test_create_meeting_success() {
630        let mut mock_repo = MockMeetingRepo::new();
631
632        mock_repo.expect_create().returning(|m| Ok(m.clone()));
633
634        let use_cases =
635            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
636
637        let request = CreateMeetingRequest {
638            organization_id: Uuid::new_v4(),
639            building_id: Uuid::new_v4(),
640            meeting_type: MeetingType::Ordinary,
641            title: "AGO 2024".to_string(),
642            description: Some("Annual assembly".to_string()),
643            scheduled_date: Utc::now() + Duration::days(30),
644            location: "Salle communale".to_string(),
645            is_second_convocation: false,
646        };
647
648        let result = use_cases.create_meeting(request).await;
649        assert!(result.is_ok());
650        let response = result.unwrap();
651        assert_eq!(response.title, "AGO 2024");
652        assert_eq!(response.status, MeetingStatus::Scheduled);
653    }
654
655    // ---------------------------------------------------------------
656    // 2. Create meeting with invalid data (empty title)
657    // ---------------------------------------------------------------
658    #[tokio::test]
659    async fn test_create_meeting_empty_title_fails() {
660        let mock_repo = MockMeetingRepo::new();
661        let use_cases =
662            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
663
664        let request = CreateMeetingRequest {
665            organization_id: Uuid::new_v4(),
666            building_id: Uuid::new_v4(),
667            meeting_type: MeetingType::Ordinary,
668            title: "".to_string(),
669            description: None,
670            scheduled_date: Utc::now() + Duration::days(30),
671            location: "Salle communale".to_string(),
672            is_second_convocation: false,
673        };
674
675        let result = use_cases.create_meeting(request).await;
676        assert!(result.is_err());
677        assert!(result.unwrap_err().contains("Title cannot be empty"));
678    }
679
680    // ---------------------------------------------------------------
681    // 3. Create meeting with empty location
682    // ---------------------------------------------------------------
683    #[tokio::test]
684    async fn test_create_meeting_empty_location_fails() {
685        let mock_repo = MockMeetingRepo::new();
686        let use_cases =
687            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
688
689        let request = CreateMeetingRequest {
690            organization_id: Uuid::new_v4(),
691            building_id: Uuid::new_v4(),
692            meeting_type: MeetingType::Extraordinary,
693            title: "AGE 2024".to_string(),
694            description: None,
695            scheduled_date: Utc::now() + Duration::days(15),
696            location: "".to_string(),
697            is_second_convocation: false,
698        };
699
700        let result = use_cases.create_meeting(request).await;
701        assert!(result.is_err());
702        assert!(result.unwrap_err().contains("Location cannot be empty"));
703    }
704
705    // ---------------------------------------------------------------
706    // 4. Get meeting by ID — found
707    // ---------------------------------------------------------------
708    #[tokio::test]
709    async fn test_get_meeting_found() {
710        let building_id = Uuid::new_v4();
711        let org_id = Uuid::new_v4();
712        let meeting = make_meeting(building_id, org_id);
713        let meeting_id = meeting.id;
714
715        let mut mock_repo = MockMeetingRepo::new();
716        mock_repo
717            .expect_find_by_id()
718            .withf(move |id| *id == meeting_id)
719            .returning(move |_| Ok(Some(make_meeting(building_id, org_id))));
720
721        let use_cases =
722            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
723
724        let result = use_cases.get_meeting(meeting_id).await;
725        assert!(result.is_ok());
726        assert!(result.unwrap().is_some());
727    }
728
729    // ---------------------------------------------------------------
730    // 5. Get meeting by ID — not found
731    // ---------------------------------------------------------------
732    #[tokio::test]
733    async fn test_get_meeting_not_found() {
734        let mut mock_repo = MockMeetingRepo::new();
735        mock_repo.expect_find_by_id().returning(|_| Ok(None));
736
737        let use_cases =
738            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
739
740        let result = use_cases.get_meeting(Uuid::new_v4()).await;
741        assert!(result.is_ok());
742        assert!(result.unwrap().is_none());
743    }
744
745    // ---------------------------------------------------------------
746    // 6. List meetings by building
747    // ---------------------------------------------------------------
748    #[tokio::test]
749    async fn test_list_meetings_by_building() {
750        let building_id = Uuid::new_v4();
751        let org_id = Uuid::new_v4();
752
753        let mut mock_repo = MockMeetingRepo::new();
754        mock_repo
755            .expect_find_by_building()
756            .withf(move |id| *id == building_id)
757            .returning(move |_| {
758                Ok(vec![
759                    make_meeting(building_id, org_id),
760                    make_meeting(building_id, org_id),
761                ])
762            });
763
764        let use_cases =
765            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
766
767        let result = use_cases.list_meetings_by_building(building_id).await;
768        assert!(result.is_ok());
769        assert_eq!(result.unwrap().len(), 2);
770    }
771
772    // ---------------------------------------------------------------
773    // 7. Update meeting — success
774    // ---------------------------------------------------------------
775    #[tokio::test]
776    async fn test_update_meeting_success() {
777        let building_id = Uuid::new_v4();
778        let org_id = Uuid::new_v4();
779        let meeting = make_meeting(building_id, org_id);
780        let meeting_id = meeting.id;
781        let meeting_clone = meeting.clone();
782
783        let mut mock_repo = MockMeetingRepo::new();
784        mock_repo
785            .expect_find_by_id()
786            .withf(move |id| *id == meeting_id)
787            .returning(move |_| Ok(Some(meeting_clone.clone())));
788
789        mock_repo.expect_update().returning(|m| Ok(m.clone()));
790
791        let use_cases =
792            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
793
794        let request = UpdateMeetingRequest {
795            title: Some("Renamed AGO".to_string()),
796            description: Some("Updated description".to_string()),
797            scheduled_date: None,
798            location: None,
799        };
800
801        let result = use_cases.update_meeting(meeting_id, request).await;
802        assert!(result.is_ok());
803        let response = result.unwrap();
804        assert_eq!(response.title, "Renamed AGO");
805    }
806
807    // ---------------------------------------------------------------
808    // 8. Update meeting — not found
809    // ---------------------------------------------------------------
810    #[tokio::test]
811    async fn test_update_meeting_not_found() {
812        let mut mock_repo = MockMeetingRepo::new();
813        mock_repo.expect_find_by_id().returning(|_| Ok(None));
814
815        let use_cases =
816            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
817
818        let request = UpdateMeetingRequest {
819            title: Some("New title".to_string()),
820            description: None,
821            scheduled_date: None,
822            location: None,
823        };
824
825        let result = use_cases.update_meeting(Uuid::new_v4(), request).await;
826        assert!(result.is_err());
827        assert!(result.unwrap_err().contains("Meeting not found"));
828    }
829
830    // ---------------------------------------------------------------
831    // 9. Update meeting — empty title rejected
832    // ---------------------------------------------------------------
833    #[tokio::test]
834    async fn test_update_meeting_empty_title_rejected() {
835        let building_id = Uuid::new_v4();
836        let org_id = Uuid::new_v4();
837        let meeting = make_meeting(building_id, org_id);
838        let meeting_id = meeting.id;
839        let meeting_clone = meeting.clone();
840
841        let mut mock_repo = MockMeetingRepo::new();
842        mock_repo
843            .expect_find_by_id()
844            .returning(move |_| Ok(Some(meeting_clone.clone())));
845
846        let use_cases =
847            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
848
849        let request = UpdateMeetingRequest {
850            title: Some("".to_string()),
851            description: None,
852            scheduled_date: None,
853            location: None,
854        };
855
856        let result = use_cases.update_meeting(meeting_id, request).await;
857        assert!(result.is_err());
858        assert!(result.unwrap_err().contains("Title cannot be empty"));
859    }
860
861    // ---------------------------------------------------------------
862    // 10. Delete meeting
863    // ---------------------------------------------------------------
864    #[tokio::test]
865    async fn test_delete_meeting_success() {
866        let meeting_id = Uuid::new_v4();
867
868        let mut mock_repo = MockMeetingRepo::new();
869        mock_repo
870            .expect_delete()
871            .withf(move |id| *id == meeting_id)
872            .returning(|_| Ok(true));
873
874        let use_cases =
875            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
876
877        let result = use_cases.delete_meeting(meeting_id).await;
878        assert!(result.is_ok());
879        assert!(result.unwrap());
880    }
881
882    // ---------------------------------------------------------------
883    // 11. Validate quorum — reached (>50%)
884    // ---------------------------------------------------------------
885    #[tokio::test]
886    async fn test_validate_quorum_reached() {
887        let building_id = Uuid::new_v4();
888        let org_id = Uuid::new_v4();
889        let meeting = make_meeting(building_id, org_id);
890        let meeting_id = meeting.id;
891        let meeting_clone = meeting.clone();
892
893        let mut mock_repo = MockMeetingRepo::new();
894        mock_repo
895            .expect_find_by_id()
896            .withf(move |id| *id == meeting_id)
897            .returning(move |_| Ok(Some(meeting_clone.clone())));
898
899        mock_repo.expect_update().returning(|m| Ok(m.clone()));
900
901        let use_cases =
902            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
903
904        // 600/1000 = 60% → quorum reached
905        let result = use_cases
906            .validate_quorum(meeting_id, dec!(600), dec!(1000))
907            .await;
908        assert!(result.is_ok());
909        let (reached, response) = result.unwrap();
910        assert!(reached);
911        assert!(response.quorum_validated);
912        assert!((response.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
913    }
914
915    // ---------------------------------------------------------------
916    // 12. Validate quorum — not reached (<=50%)
917    // ---------------------------------------------------------------
918    #[tokio::test]
919    async fn test_validate_quorum_not_reached() {
920        let building_id = Uuid::new_v4();
921        let org_id = Uuid::new_v4();
922        let meeting = make_meeting(building_id, org_id);
923        let meeting_id = meeting.id;
924        let meeting_clone = meeting.clone();
925
926        let mut mock_repo = MockMeetingRepo::new();
927        mock_repo
928            .expect_find_by_id()
929            .withf(move |id| *id == meeting_id)
930            .returning(move |_| Ok(Some(meeting_clone.clone())));
931
932        mock_repo.expect_update().returning(|m| Ok(m.clone()));
933
934        // No convocation_use_cases set, so second convocation won't be triggered
935        let use_cases =
936            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
937
938        // 400/1000 = 40% → quorum NOT reached
939        let result = use_cases
940            .validate_quorum(meeting_id, dec!(400), dec!(1000))
941            .await;
942        assert!(result.is_ok());
943        let (reached, response) = result.unwrap();
944        assert!(!reached);
945        assert!(!response.quorum_validated);
946        assert!((response.quorum_percentage.unwrap() - 40.0).abs() < 0.01);
947    }
948
949    // ---------------------------------------------------------------
950    // 13. Validate quorum — meeting not found
951    // ---------------------------------------------------------------
952    #[tokio::test]
953    async fn test_validate_quorum_meeting_not_found() {
954        let mut mock_repo = MockMeetingRepo::new();
955        mock_repo.expect_find_by_id().returning(|_| Ok(None));
956
957        let use_cases =
958            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
959
960        let result = use_cases
961            .validate_quorum(Uuid::new_v4(), dec!(600), dec!(1000))
962            .await;
963        assert!(result.is_err());
964        assert!(result.unwrap_err().contains("Meeting not found"));
965    }
966
967    // ---------------------------------------------------------------
968    // 14. Complete meeting via use case
969    // ---------------------------------------------------------------
970    #[tokio::test]
971    async fn test_complete_meeting_success() {
972        let building_id = Uuid::new_v4();
973        let org_id = Uuid::new_v4();
974        let meeting = make_meeting(building_id, org_id);
975        let meeting_id = meeting.id;
976        let meeting_clone = meeting.clone();
977
978        let mut mock_repo = MockMeetingRepo::new();
979        mock_repo
980            .expect_find_by_id()
981            .withf(move |id| *id == meeting_id)
982            .returning(move |_| Ok(Some(meeting_clone.clone())));
983
984        mock_repo.expect_update().returning(|m| Ok(m.clone()));
985
986        let use_cases =
987            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
988
989        let request = CompleteMeetingRequest {
990            attendees_count: 42,
991        };
992        let result = use_cases.complete_meeting(meeting_id, request).await;
993        assert!(result.is_ok());
994        let response = result.unwrap();
995        assert_eq!(response.status, MeetingStatus::Completed);
996        assert_eq!(response.attendees_count, Some(42));
997    }
998
999    // ---------------------------------------------------------------
1000    // 15. Cancel meeting via use case
1001    // ---------------------------------------------------------------
1002    #[tokio::test]
1003    async fn test_cancel_meeting_success() {
1004        let building_id = Uuid::new_v4();
1005        let org_id = Uuid::new_v4();
1006        let meeting = make_meeting(building_id, org_id);
1007        let meeting_id = meeting.id;
1008        let meeting_clone = meeting.clone();
1009
1010        let mut mock_repo = MockMeetingRepo::new();
1011        mock_repo
1012            .expect_find_by_id()
1013            .withf(move |id| *id == meeting_id)
1014            .returning(move |_| Ok(Some(meeting_clone.clone())));
1015
1016        mock_repo.expect_update().returning(|m| Ok(m.clone()));
1017
1018        let use_cases =
1019            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
1020
1021        let result = use_cases.cancel_meeting(meeting_id).await;
1022        assert!(result.is_ok());
1023        assert_eq!(result.unwrap().status, MeetingStatus::Cancelled);
1024    }
1025
1026    // ---------------------------------------------------------------
1027    // 16. Add agenda item via use case
1028    // ---------------------------------------------------------------
1029    #[tokio::test]
1030    async fn test_add_agenda_item_success() {
1031        let building_id = Uuid::new_v4();
1032        let org_id = Uuid::new_v4();
1033        let meeting = make_meeting(building_id, org_id);
1034        let meeting_id = meeting.id;
1035        let meeting_clone = meeting.clone();
1036
1037        let mut mock_repo = MockMeetingRepo::new();
1038        mock_repo
1039            .expect_find_by_id()
1040            .withf(move |id| *id == meeting_id)
1041            .returning(move |_| Ok(Some(meeting_clone.clone())));
1042
1043        mock_repo.expect_update().returning(|m| Ok(m.clone()));
1044
1045        let use_cases =
1046            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
1047
1048        let request = AddAgendaItemRequest {
1049            item: "Approbation des comptes".to_string(),
1050        };
1051        let result = use_cases.add_agenda_item(meeting_id, request).await;
1052        assert!(result.is_ok());
1053        let response = result.unwrap();
1054        assert_eq!(response.agenda.len(), 1);
1055        assert_eq!(response.agenda[0], "Approbation des comptes");
1056    }
1057
1058    // ---------------------------------------------------------------
1059    // 17. Validate quorum — exact 50% NOT reached (Art. 3.87 §5: >50% strict)
1060    // ---------------------------------------------------------------
1061    #[tokio::test]
1062    async fn test_validate_quorum_exact_50_percent_not_reached() {
1063        let building_id = Uuid::new_v4();
1064        let org_id = Uuid::new_v4();
1065        let meeting = make_meeting(building_id, org_id);
1066        let meeting_id = meeting.id;
1067        let meeting_clone = meeting.clone();
1068
1069        let mut mock_repo = MockMeetingRepo::new();
1070        mock_repo
1071            .expect_find_by_id()
1072            .withf(move |id| *id == meeting_id)
1073            .returning(move |_| Ok(Some(meeting_clone.clone())));
1074
1075        mock_repo.expect_update().returning(|m| Ok(m.clone()));
1076
1077        let use_cases =
1078            MeetingUseCases::new(Arc::new(mock_repo)).with_acp_resolution(mock_building_repo());
1079
1080        // 500/1000 = exactly 50% → quorum NOT reached (Art. 3.87 §5: strictly >50%)
1081        let result = use_cases
1082            .validate_quorum(meeting_id, dec!(500), dec!(1000))
1083            .await;
1084        assert!(result.is_ok());
1085        let (reached, response) = result.unwrap();
1086        assert!(!reached);
1087        assert!(!response.quorum_validated);
1088        assert!((response.quorum_percentage.unwrap() - 50.0).abs() < 0.01);
1089    }
1090}