Skip to main content

koprogo_api/application/use_cases/
generate_ago_resolutions_use_case.rs

1//! Story 4.6 (#581) — `generate_ago_resolutions(meeting_id)`.
2//!
3//! Art. 3.89 § 5, 12° Code Civil belge impose que l'évaluation de
4//! l'exécution des contrats en cours figure à l'ordre du jour de
5//! l'assemblée ordinaire. La rendre optionnelle reviendrait à laisser le
6//! syndic, qui y est évalué, décider lui-même s'il s'expose au vote — c'est
7//! pourquoi ce point est ajouté d'office, jamais proposé.
8//!
9//! Restreint à l'AGO (`MeetingType::Ordinary`) : l'obligation légale porte
10//! sur l'assemblée ordinaire, l'étendre à l'AGE ajouterait à la loi.
11
12use crate::application::error::AppError;
13use crate::application::ports::{MeetingRepository, ResolutionRepository};
14use crate::domain::entities::{MeetingType, Resolution};
15use std::sync::Arc;
16use uuid::Uuid;
17
18pub struct GenerateAgoResolutionsUseCase {
19    meeting_repository: Arc<dyn MeetingRepository>,
20    resolution_repository: Arc<dyn ResolutionRepository>,
21}
22
23impl GenerateAgoResolutionsUseCase {
24    pub fn new(
25        meeting_repository: Arc<dyn MeetingRepository>,
26        resolution_repository: Arc<dyn ResolutionRepository>,
27    ) -> Self {
28        Self {
29            meeting_repository,
30            resolution_repository,
31        }
32    }
33
34    /// Ajoute d'office la résolution `EvaluationContractorsAuto` à l'AGO
35    /// désignée. Rend `Ok(vec![])` sans rien créer si la réunion est une AGE
36    /// (@edge) — et rend la résolution déjà présente plutôt que d'en créer
37    /// une seconde si l'appel est répété (idempotence : une convocation
38    /// modifiée peut redéclencher la génération).
39    pub async fn generate_ago_resolutions(
40        &self,
41        meeting_id: Uuid,
42    ) -> Result<Vec<Resolution>, AppError> {
43        let meeting = self
44            .meeting_repository
45            .find_by_id(meeting_id)
46            .await
47            .map_err(AppError::from)?
48            .ok_or_else(|| AppError::NotFound(format!("meeting {}", meeting_id)))?;
49
50        if meeting.meeting_type != MeetingType::Ordinary {
51            return Ok(Vec::new());
52        }
53
54        let existantes = self
55            .resolution_repository
56            .find_by_meeting_id(meeting_id)
57            .await
58            .map_err(AppError::from)?;
59        if let Some(deja_generee) = existantes.into_iter().find(|r| r.is_auto_generated()) {
60            return Ok(vec![deja_generee]);
61        }
62
63        let resolution = Resolution::new_evaluation_contractors_auto(meeting_id);
64        let created = self
65            .resolution_repository
66            .create(&resolution)
67            .await
68            .map_err(AppError::from)?;
69        Ok(vec![created])
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::domain::entities::{Meeting, ResolutionStatus};
77    use async_trait::async_trait;
78    use std::collections::HashMap;
79    use std::sync::Mutex;
80
81    struct MockMeetingRepository {
82        meetings: Mutex<HashMap<Uuid, Meeting>>,
83    }
84
85    impl MockMeetingRepository {
86        fn new() -> Self {
87            Self {
88                meetings: Mutex::new(HashMap::new()),
89            }
90        }
91
92        fn pose(&self, meeting: Meeting) {
93            self.meetings.lock().unwrap().insert(meeting.id, meeting);
94        }
95    }
96
97    #[async_trait]
98    impl MeetingRepository for MockMeetingRepository {
99        async fn create(&self, meeting: &Meeting) -> Result<Meeting, String> {
100            self.meetings
101                .lock()
102                .unwrap()
103                .insert(meeting.id, meeting.clone());
104            Ok(meeting.clone())
105        }
106        async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String> {
107            Ok(self.meetings.lock().unwrap().get(&id).cloned())
108        }
109        async fn find_by_building(&self, _building_id: Uuid) -> Result<Vec<Meeting>, String> {
110            Ok(vec![])
111        }
112        async fn update(&self, meeting: &Meeting) -> Result<Meeting, String> {
113            self.meetings
114                .lock()
115                .unwrap()
116                .insert(meeting.id, meeting.clone());
117            Ok(meeting.clone())
118        }
119        async fn delete(&self, id: Uuid) -> Result<bool, String> {
120            Ok(self.meetings.lock().unwrap().remove(&id).is_some())
121        }
122        async fn find_all_paginated(
123            &self,
124            _page_request: &crate::application::dto::PageRequest,
125            _organization_id: Option<Uuid>,
126        ) -> Result<(Vec<Meeting>, i64), String> {
127            Ok((vec![], 0))
128        }
129    }
130
131    struct MockResolutionRepository {
132        resolutions: Mutex<HashMap<Uuid, Resolution>>,
133    }
134
135    impl MockResolutionRepository {
136        fn new() -> Self {
137            Self {
138                resolutions: Mutex::new(HashMap::new()),
139            }
140        }
141    }
142
143    #[async_trait]
144    impl ResolutionRepository for MockResolutionRepository {
145        async fn create(&self, resolution: &Resolution) -> Result<Resolution, String> {
146            self.resolutions
147                .lock()
148                .unwrap()
149                .insert(resolution.id, resolution.clone());
150            Ok(resolution.clone())
151        }
152        async fn find_by_id(&self, id: Uuid) -> Result<Option<Resolution>, String> {
153            Ok(self.resolutions.lock().unwrap().get(&id).cloned())
154        }
155        async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Vec<Resolution>, String> {
156            Ok(self
157                .resolutions
158                .lock()
159                .unwrap()
160                .values()
161                .filter(|r| r.meeting_id == meeting_id)
162                .cloned()
163                .collect())
164        }
165        async fn find_by_status(
166            &self,
167            status: ResolutionStatus,
168        ) -> Result<Vec<Resolution>, String> {
169            Ok(self
170                .resolutions
171                .lock()
172                .unwrap()
173                .values()
174                .filter(|r| r.status == status)
175                .cloned()
176                .collect())
177        }
178        async fn update(&self, resolution: &Resolution) -> Result<Resolution, String> {
179            self.resolutions
180                .lock()
181                .unwrap()
182                .insert(resolution.id, resolution.clone());
183            Ok(resolution.clone())
184        }
185        async fn delete(&self, id: Uuid) -> Result<bool, String> {
186            Ok(self.resolutions.lock().unwrap().remove(&id).is_some())
187        }
188        async fn update_vote_counts(
189            &self,
190            _resolution_id: Uuid,
191            _vote_count_pour: i32,
192            _vote_count_contre: i32,
193            _vote_count_abstention: i32,
194            _total_voting_power_pour: rust_decimal::Decimal,
195            _total_voting_power_contre: rust_decimal::Decimal,
196            _total_voting_power_abstention: rust_decimal::Decimal,
197        ) -> Result<(), String> {
198            Ok(())
199        }
200        async fn close_voting(
201            &self,
202            _resolution_id: Uuid,
203            _final_status: ResolutionStatus,
204            _voix_plafonnees: Option<serde_json::Value>,
205        ) -> Result<(), String> {
206            Ok(())
207        }
208        async fn get_meeting_vote_summary(
209            &self,
210            meeting_id: Uuid,
211        ) -> Result<Vec<Resolution>, String> {
212            self.find_by_meeting_id(meeting_id).await
213        }
214    }
215
216    fn ago(id: Uuid) -> Meeting {
217        Meeting::new(
218            Uuid::new_v4(),
219            Uuid::new_v4(),
220            Uuid::new_v4(),
221            MeetingType::Ordinary,
222            "AGO 2026".to_string(),
223            None,
224            chrono::Utc::now() + chrono::Duration::days(30),
225            "Salle des fêtes".to_string(),
226        )
227        .map(|mut m| {
228            m.id = id;
229            m
230        })
231        .expect("meeting AGO de test")
232    }
233
234    fn age(id: Uuid) -> Meeting {
235        Meeting::new(
236            Uuid::new_v4(),
237            Uuid::new_v4(),
238            Uuid::new_v4(),
239            MeetingType::Extraordinary,
240            "AGE 2026".to_string(),
241            None,
242            chrono::Utc::now() + chrono::Duration::days(30),
243            "Salle des fêtes".to_string(),
244        )
245        .map(|mut m| {
246            m.id = id;
247            m
248        })
249        .expect("meeting AGE de test")
250    }
251
252    /// @happy — une AGO génère d'office sa résolution d'évaluation des
253    /// prestataires, non éditable.
254    #[tokio::test]
255    async fn happy_ago_genere_la_resolution_auto() {
256        let meeting_id = Uuid::new_v4();
257        let meeting_repo = Arc::new(MockMeetingRepository::new());
258        meeting_repo.pose(ago(meeting_id));
259        let resolution_repo = Arc::new(MockResolutionRepository::new());
260        let use_case = GenerateAgoResolutionsUseCase::new(meeting_repo, resolution_repo);
261
262        let created = use_case
263            .generate_ago_resolutions(meeting_id)
264            .await
265            .expect("génération réussie");
266
267        assert_eq!(created.len(), 1);
268        assert!(created[0].is_auto_generated());
269        assert_eq!(created[0].meeting_id, meeting_id);
270    }
271
272    /// @edge — une AGE ne déclenche aucune génération : l'obligation légale
273    /// (Art. 3.89 § 5, 12°) porte sur l'assemblée ordinaire.
274    #[tokio::test]
275    async fn edge_age_ne_genere_rien() {
276        let meeting_id = Uuid::new_v4();
277        let meeting_repo = Arc::new(MockMeetingRepository::new());
278        meeting_repo.pose(age(meeting_id));
279        let resolution_repo = Arc::new(MockResolutionRepository::new());
280        let use_case = GenerateAgoResolutionsUseCase::new(meeting_repo, resolution_repo);
281
282        let created = use_case
283            .generate_ago_resolutions(meeting_id)
284            .await
285            .expect("pas d'erreur, juste rien à générer");
286
287        assert!(created.is_empty());
288    }
289
290    /// @security — appeler la génération deux fois sur la même AGO ne crée
291    /// pas une seconde résolution d'évaluation : un doublon donnerait au
292    /// syndic une résolution à retirer sans toucher à celle qui l'évalue
293    /// réellement, contournant de fait la garantie de la story.
294    #[tokio::test]
295    async fn security_generation_repetee_ne_duplique_pas() {
296        let meeting_id = Uuid::new_v4();
297        let meeting_repo = Arc::new(MockMeetingRepository::new());
298        meeting_repo.pose(ago(meeting_id));
299        let resolution_repo = Arc::new(MockResolutionRepository::new());
300        let use_case = GenerateAgoResolutionsUseCase::new(meeting_repo, resolution_repo);
301
302        let premiere = use_case.generate_ago_resolutions(meeting_id).await.unwrap();
303        let seconde = use_case.generate_ago_resolutions(meeting_id).await.unwrap();
304
305        assert_eq!(premiere.len(), 1);
306        assert_eq!(seconde.len(), 1);
307        assert_eq!(premiere[0].id, seconde[0].id);
308    }
309
310    /// @negative — une réunion introuvable rend une erreur typée 404, pas un
311    /// panic ni une résolution fantôme.
312    #[tokio::test]
313    async fn negative_meeting_introuvable_rend_404() {
314        let meeting_repo = Arc::new(MockMeetingRepository::new());
315        let resolution_repo = Arc::new(MockResolutionRepository::new());
316        let use_case = GenerateAgoResolutionsUseCase::new(meeting_repo, resolution_repo);
317
318        let result = use_case.generate_ago_resolutions(Uuid::new_v4()).await;
319
320        assert!(matches!(result, Err(AppError::NotFound(_))));
321    }
322}